Adds a button to download all Patreon post images as a ZIP archive
当前为
// ==UserScript== // @name Patreon Post Images Downloader // @description Adds a button to download all Patreon post images as a ZIP archive // @version 1.0.0 // @author BreatFR // @namespace http://gitlab.com/breatfr // @match *://*.patreon.com/* // @require https://cdn.jsdelivr.net/npm/[email protected]/umd/index.min.js // @copyright 2025, BreatFR (https://breat.fr) // @icon https://c5.patreon.com/external/favicon/rebrand/pwa-192.png // @license AGPL-3.0-or-later; https://www.gnu.org/licenses/agpl-3.0.txt // @grant GM_xmlhttpRequest // @grant GM_download // @run-at document-end // ==/UserScript== (function() { 'use strict'; console.log('[Patreon Post Images Downloader] Script loaded'); const style = document.createElement('style'); style.textContent = ` .patreon-download-btn { align-items: center; background-color: rgba(24, 24, 24, .2); border: none; border-radius: .5em; color: #fff; cursor: pointer; display: inline-flex; flex-direction: column; font-family: poppins, cursive; font-size: 1.5rem !important; line-height: 1em; gap: 1em; justify-content: center; padding: .5em 1em; pointer-events: auto; transition: background-color .3s ease, box-shadow .3s ease; white-space: nowrap; } .patreon-download-btn:hover { background-color: rgba(255, 80, 80, .85); box-shadow: 0 0 2em rgba(255, 80, 80, .85); } @keyframes spinLoop { from { transform: rotate(0deg); } to { transform: rotate(360deg); } } .patreon-btn-icon.spin { animation: spinLoop 1s linear infinite; } @keyframes pulseLoop { 0% { transform: scale(1); opacity: 1; } 50% { transform: scale(1.1); opacity: 0.7; } 100% { transform: scale(1); opacity: 1; } } .patreon-btn-icon.pulse { animation: pulseLoop 1.8s ease-in-out infinite; } .patreon-btn-icon { font-size: 3em !important; line-height: 1em; } #top { aspect-ratio: 1 / 1; background: transparent; border: none; bottom: 1em; box-sizing: border-box; height: auto; font-size: 1.2em !important; line-height: 1 !important; padding: 0; position: fixed; right: 1em; } div[elementtiming="Post : Post Title"] { position: relative; } `; document.head.appendChild(style); // Download button function setButtonContent(btn, icon, label) { let iconEl = btn.querySelector('.patreon-btn-icon'); let labelEl = btn.querySelector('.patreon-btn-label'); if (!iconEl) { iconEl = document.createElement('div'); iconEl.className = 'patreon-btn-icon'; btn.appendChild(iconEl); } if (!labelEl) { labelEl = document.createElement('div'); labelEl.className = 'patreon-btn-label'; btn.appendChild(labelEl); } iconEl.textContent = icon; labelEl.textContent = label; } function setIconAnimation(btn, type) { const icon = btn.querySelector('.patreon-btn-icon'); icon.classList.remove('spin', 'pulse'); void icon.offsetWidth; if (type) icon.classList.add(type); } function updateIconWithAnimation(btn, icon, label, animationClass) { setButtonContent(btn, icon, label); requestAnimationFrame(() => setIconAnimation(btn, animationClass)); } function simulateNativeClick(selector) { const script = document.createElement('script'); script.textContent = ` requestAnimationFrame(() => { const el = document.querySelector('${selector}'); if (el) { const evt = new MouseEvent('click', { bubbles: true, cancelable: true }); el.dispatchEvent(evt); console.log('[Patreon Collector] ✅ Native click dispatched on lightbox container'); } }); `; document.body.appendChild(script); script.remove(); } function closeLightboxDelayed(delay = 500) { setTimeout(() => { simulateNativeClick('button[data-tag="close"]'); setTimeout(() => { const overlay = document.querySelector('[data-focus-lock-disabled="false"]'); if (overlay) { overlay.style.display = 'none'; overlay.style.opacity = '0'; overlay.style.pointerEvents = 'none'; overlay.style.visibility = 'hidden'; } }, 500); }, delay); } async function getFullSizeFromLightbox(img) { img.scrollIntoView({ behavior: 'smooth', block: 'center' }); img.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true })); await new Promise(r => setTimeout(r, 100)); const timeout = 3000; const start = Date.now(); let fullImg = null; while (Date.now() - start < timeout) { fullImg = document.querySelector('[data-target="lightbox-content"] img'); if (fullImg?.src) break; await new Promise(r => setTimeout(r, 100)); } return fullImg?.src || null; } function downloadImage(url) { console.log(`[Patreon Collector] Requesting image: ${url}`); return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url: url, responseType: 'blob', onload: function(response) { if (response.status === 200 && response.response.size > 0) { resolve({ blob: response.response }); } else { reject(new Error(`Download failed or empty blob for ${url}`)); } }, onerror: reject }); }); } function blobToUint8Array(blob) { return new Promise((resolve, reject) => { const reader = new FileReader(); reader.onload = () => resolve(new Uint8Array(reader.result)); reader.onerror = reject; reader.readAsArrayBuffer(blob); }); } function addDownloadButton(btn) { btn.addEventListener('click', async () => { btn.disabled = true; updateIconWithAnimation(btn, '🌀', 'Collecting full-size images...', 'spin'); const rawImages = Array.from(document.querySelectorAll('.image-grid > img')); console.log(`[Patreon Collector] 🎯 Ciblé : ${rawImages.length} image(s) dans .image-grid`); const files = {}; const seen = new Set(); let index = 1; for (const img of rawImages) { const fullSize = await getFullSizeFromLightbox(img); const finalUrl = fullSize || img.src; if (!finalUrl || seen.has(finalUrl)) continue; seen.add(finalUrl); const rawName = finalUrl.split('/').pop(); const baseName = rawName.split('?')[0]; const ext = baseName.includes('.') ? baseName.split('.').pop() : 'jpg'; const filename = `${index.toString().padStart(2, '0')}.${ext}`; try { const { blob } = await downloadImage(finalUrl); if (!blob || blob.size === 0) continue; const uint8 = await blobToUint8Array(blob); files[filename] = uint8; updateIconWithAnimation(btn, '📥', `Downloading ${filename}`, 'pulse'); index++; } catch (e) { console.warn(`[Patreon Collector] Failed to download ${finalUrl}`, e); } } if (index === 1) { alert('All image downloads failed.'); btn.disabled = false; updateIconWithAnimation(btn, '📦', 'Download all post images', null); return; } updateIconWithAnimation(btn, '📦', `Creating ZIP (${index - 1} images)...`, null); try { const zipped = fflate.zipSync(files); const blob = new Blob([zipped], { type: 'application/zip' }); const titleElement = document.querySelector('[data-tag="post-card"] div[elementtiming="Post : Post Title"]'); const zipName = titleElement ? titleElement.textContent.trim().replace(/[\\/:*?"<>|]/g, '_') : 'patreon_images'; GM_download({ url: URL.createObjectURL(blob), name: `${zipName}.zip`, saveAs: true, onerror: err => { console.error('[Patreon Collector] ❌ GM_download failed:', err); alert('ZIP download failed.'); } }); closeLightboxDelayed(500); } catch (e) { console.error('[Patreon Collector] ❌ ZIP compression error:', e); alert('ZIP creation failed. Check console for details.'); } updateIconWithAnimation(btn, '✅', `${index - 1} images downloaded`, null); setTimeout(() => { updateIconWithAnimation(btn, '📦', 'Download all post images', null); btn.disabled = false; }, 3000); }); } function waitForTitleAndInjectButton(retries = 20) { const isPostPage = location.pathname.startsWith('/posts/'); if (!isPostPage) return; const tryInject = () => { const titleDiv = document.querySelector('[data-tag="post-card"] div[elementtiming="Post : Post Title"]'); if (titleDiv && titleDiv.parentNode) { const h1 = titleDiv.parentNode; h1.style.alignItems = 'flex-start'; h1.style.display = 'flex'; h1.style.flexDirection = 'column'; h1.style.gap = '.2em'; h1.style.position = 'relative'; if (!h1.querySelector('.patreon-download-btn')) { const btn = document.createElement('button'); btn.className = 'patreon-download-btn'; btn.innerHTML = ` <div class="patreon-btn-icon">📦</div> <div class="patreon-btn-label">Download all post images</div> `; h1.appendChild(btn); addDownloadButton(btn); // 👈 liaison ici } return true; } return false; }; let attempts = 0; const interval = setInterval(() => { if (tryInject() || ++attempts >= retries) { clearInterval(interval); } }, 300); } waitForTitleAndInjectButton(); // Back to top const btn = document.createElement('button'); btn.id = 'top'; btn.setAttribute('aria-label', 'Scroll to top'); btn.setAttribute('title', 'Scroll to top'); setButtonContent(btn, '🔝', '') document.body.appendChild(btn); const mybutton = document.getElementById("top"); window.onscroll = function () { scrollFunction(); }; function scrollFunction() { if ( document.body.scrollTop > 20 || document.documentElement.scrollTop > 20 ) { mybutton.style.display = "block"; } else { mybutton.style.display = "none"; } } mybutton.addEventListener("click", backToTop); function backToTop() { window.scrollTo({ top: 0, behavior: 'smooth' }); } })();