// ==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 loadImageFromBlob(blob) {
return new Promise((resolve, reject) => {
const img = new Image();
img.crossOrigin = 'anonymous';
img.onload = () => resolve(img);
img.onerror = reject;
img.src = URL.createObjectURL(blob);
});
}
function hasTransparency(img) {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
const { data } = ctx.getImageData(0, 0, canvas.width, canvas.height);
for (let i = 3; i < data.length; i += 4) {
if (data[i] < 255) return true;
}
return false;
}
function convertToJPEG(img) {
const canvas = document.createElement('canvas');
canvas.width = img.naturalWidth;
canvas.height = img.naturalHeight;
const ctx = canvas.getContext('2d');
ctx.drawImage(img, 0, 0);
return canvas.toDataURL('image/jpeg', 1.0);
}
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));
}
async function getFullSizeFromLightbox(img) {
img.scrollIntoView({ behavior: 'smooth', block: 'center' });
img.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
if (!document.getElementById('patreon-lightbox-mask')) {
const style = document.createElement('style');
style.id = 'patreon-lightbox-mask';
style.textContent = `
[data-focus-lock-disabled="false"],
[data-focus-lock-disabled="false"] * {
opacity: 0 !important;
pointer-events: none !important;
visibility: hidden !important;
transition: opacity 0.3s ease !important;
}
`;
document.head.appendChild(style);
console.log('[Patreon Collector] 🫥 Lightbox mask injected');
}
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));
}
const closeBtn = document.querySelector('button[data-tag="close"]');
if (closeBtn) {
closeBtn.dispatchEvent(new MouseEvent('click', { bubbles: true, cancelable: true }));
console.log('[Patreon Collector] 🧯 Lightbox closed');
}
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, .image-carousel > img'));
console.log(`[Patreon Collector] 🎯 Ciblé : ${rawImages.length} image(s) dans .image-grid ou .image-carousel`);
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];
function generateRandomHex(length = 8) {
return [...crypto.getRandomValues(new Uint8Array(length / 2))]
.map(b => b.toString(16).padStart(2, '0'))
.join('');
}
const ext = baseName.includes('.') ? baseName.split('.').pop() : 'jpg';
const filename = `${generateRandomHex()}.${ext}`;
try {
const { blob } = await downloadImage(finalUrl);
if (!blob || blob.size === 0) continue;
let uint8;
let finalFilename = filename;
if (ext === 'png') {
try {
const imgEl = await loadImageFromBlob(blob);
if (!hasTransparency(imgEl)) {
const jpegDataUrl = convertToJPEG(imgEl);
const jpegBlob = await (await fetch(jpegDataUrl)).blob();
uint8 = await blobToUint8Array(jpegBlob);
finalFilename = filename.replace(/\.png$/i, '.jpg');
console.log(`[Patreon Collector] Converted PNG to JPEG: ${finalFilename}`);
} else {
uint8 = await blobToUint8Array(blob);
console.log(`[Patreon Collector] PNG with transparency kept: ${filename}`);
}
} catch (e) {
console.warn(`[Patreon Collector] Transparency check failed for ${filename}`, e);
uint8 = await blobToUint8Array(blob); // fallback
}
} else {
uint8 = await blobToUint8Array(blob);
}
files[finalFilename] = uint8;
updateIconWithAnimation(btn, '📥', `Downloading image ${Object.keys(files).length}/${rawImages.length / 2}`, 'pulse');
index++;
} catch (e) {
console.warn(`[Patreon Collector] Failed to download ${finalUrl}`, e);
}
}
if (index === 1) {
alert('All image downloads failed.');
const mask = document.getElementById('patreon-lightbox-mask');
if (mask) mask.remove();
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.');
}
});
updateIconWithAnimation(btn, '✅', `${index - 1} images downloaded`, null);
} catch (e) {
console.error('[Patreon Collector] ❌ ZIP compression error:', e);
alert('ZIP creation failed. Check console for details.');
} finally {
setTimeout(() => {
document.getElementById('patreon-lightbox-mask')?.remove();
console.log('[Patreon Collector] 🧼 Lightbox mask removed');
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' });
}
})();