Greasy Fork is available in English.

Patreon Post Images Downloader

Adds a button to download all Patreon post images as ZIP (optionally split into parts)

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Patreon Post Images Downloader
// @description  Adds a button to download all Patreon post images as ZIP (optionally split into parts)
// @version      1.0.2
// @author       BreatFR
// @namespace    http://breat.fr
// @homepageURL  https://usercssjs.breat.fr/p/patreon
// @supportURL   https://discord.gg/W7FKesJ9aG
// @match        *://*.patreon.com/*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/umd/index.min.js
// @copyright    2025, BreatFR (https://breat.fr)
// @icon         https://breat.fr/static/images/userscripts-et-userstyles/p/patreon/icon.jpg
// @license      AGPL-3.0-or-later; https://www.gnu.org/licenses/agpl-3.0.txt
// @grant        GM_download
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_setValue
// @grant        GM_xmlhttpRequest
// @run-at       document-end
// ==/UserScript==

/* ==Credits==
Website         https://breat.fr
Bluesky			https://bsky.app/profile/breatfroff.bsky.social
facebook        https://www.facebook.com/breatfroff
mastodon        https://mastodon.social/@breat_fr
telegram        https://t.me/breatfr
vk              https://vk.com/breatfroff
X (twitter)     https://x.com/breatfroff
==/Credits== */

/* ==Support==
brave Creators  https://publishers.basicattentiontoken.org/c/breatfr
Buy me a coffee https://buymeacoffee.com/breatfr
ko-fi           https://ko-fi.com/breatfr
PayPal          https://paypal.me/breat
==/Support== */

(function () {
  'use strict';

  const LOG_PREFIX = '[Patreon Post Images Downloader]';

  console.log(`${LOG_PREFIX} Script loaded`);

  // =========================
  // CONFIG
  // =========================
  const DEFAULT_MAX_PART_MB = 500;
  const MAX_PART_MB = Number(GM_getValue('max_part_mb', DEFAULT_MAX_PART_MB));
  const MAX_PART_BYTES = MAX_PART_MB * 1024 * 1024;

  // =========================
  // MENU
  // =========================
  GM_registerMenuCommand(`Set max ZIP part size (current: ${MAX_PART_MB} MB)`, () => {
    const input = prompt('Max size per ZIP part (MB):', String(MAX_PART_MB));
    if (input == null) return;

    const n = Number(input);
    if (!Number.isFinite(n) || n < 10) {
      alert('Please enter a number >= 10 (MB).');
      return;
    }

    GM_setValue('max_part_mb', Math.round(n));
    alert(`Saved: ${Math.round(n)} MB\nReload the page to apply.`);
  });

  // =========================
  // STYLES
  // =========================
  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;
      display: none;
    }

    div[elementtiming="Post : Post Title"] { position: relative; }
  `;
  document.head.appendChild(style);

  // =========================
  // HELPERS
  // =========================
  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');
    if (!icon) return;
    icon.classList.remove('spin', 'pulse');
    void icon.offsetWidth; // restart animation
    if (type) icon.classList.add(type);
  }

  function updateIconWithAnimation(btn, icon, label, animationClass) {
    setButtonContent(btn, icon, label);
    requestAnimationFrame(() => setIconAnimation(btn, animationClass));
  }

  function pad3(n) { return String(n).padStart(3, '0'); }

  function sanitizeFilename(name) {
    return (name ?? '')
      .replace(/[\\/:*?"<>|]/g, '_')
      .replace(/\s+/g, ' ')
      .trim();
  }

  function generateRandomHex(length = 8) {
    return [...crypto.getRandomValues(new Uint8Array(length / 2))]
      .map(b => b.toString(16).padStart(2, '0'))
      .join('');
  }

  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 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) {
    // NOTE: full-res scan can be heavy on huge images; kept as-is since you had it.
    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);
  }

  // =========================
  // NETWORK
  // =========================
  function downloadImage(url) {
    console.log(`${LOG_PREFIX} Requesting image: ${url}`);
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        responseType: 'blob',
        onload: (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
      });
    });
  }

  // HEAD -> Content-Length, fallback GET Range 0-0 -> Content-Range
  function getRemoteSizeBytes(url) {
    return new Promise((resolve) => {
      GM_xmlhttpRequest({
        method: 'HEAD',
        url,
        onload: (resp) => {
          const headers = (resp.responseHeaders || '').toLowerCase();
          const m = headers.match(/content-length:\s*(\d+)/i);
          if (m) return resolve(Number(m[1]));

          GM_xmlhttpRequest({
            method: 'GET',
            url,
            headers: { Range: 'bytes=0-0' },
            onload: (r2) => {
              const h2 = (r2.responseHeaders || '').toLowerCase();
              const cr = h2.match(/content-range:\s*bytes\s+\d+-\d+\/(\d+)/i);
              if (cr) return resolve(Number(cr[1]));
              resolve(null);
            },
            onerror: () => resolve(null)
          });
        },
        onerror: () => resolve(null)
      });
    });
  }

  // Do not wait for GM_download.onload (often unreliable in script managers).
  function triggerDownloadBlob(filename, blob) {
    const url = URL.createObjectURL(blob);
    try {
      GM_download({
        url,
        name: filename,
        saveAs: true,
        onerror: (err) => console.error(`${LOG_PREFIX} ❌ GM_download error:`, err)
      });
    } finally {
      setTimeout(() => URL.revokeObjectURL(url), 60_000);
    }
  }

  // =========================
  // LIGHTBOX FULL SIZE URL
  // =========================
  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 maskStyle = document.createElement('style');
      maskStyle.id = 'patreon-lightbox-mask';
      maskStyle.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(maskStyle);
      console.log(`${LOG_PREFIX} 🫥 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(`${LOG_PREFIX} 🧯 Lightbox closed`);
    }

    return fullImg?.src || null;
  }

  // =========================
  // MAIN BUTTON LOGIC
  // =========================
  function addDownloadButton(btn) {
    btn.addEventListener('click', async () => {
      btn.disabled = true;
      updateIconWithAnimation(btn, '🌀', 'Collecting image URLs...', 'spin');

      const rawImages = Array.from(document.querySelectorAll('.image-grid > img, .image-carousel > img'));
      console.log(`${LOG_PREFIX} 🎯 Targeted: ${rawImages.length} image(s)`);

      const titleElement = document.querySelector('[data-tag="post-card"] div[elementtiming="Post : Post Title"]');
      const rawTitle = titleElement?.textContent?.trim();
      const zipBaseName = sanitizeFilename(rawTitle) || 'patreon-post';

      const seen = new Set();
      const urls = [];

      try {
        // 1) Collect full-size URLs (lightbox)
        for (let i = 0; i < rawImages.length; i++) {
          const img = rawImages[i];
          const fullSize = await getFullSizeFromLightbox(img);
          const finalUrl = fullSize || img.src;
          if (!finalUrl || seen.has(finalUrl)) continue;
          seen.add(finalUrl);
          urls.push(finalUrl);

          updateIconWithAnimation(btn, '🔎', `Found ${urls.length} image(s)...`, 'pulse');
        }

        if (!urls.length) {
          alert('No images found.');
          return;
        }

        // 2) Estimate TOTAL size (HEAD / Range)
        updateIconWithAnimation(btn, '🧮', `Estimating total size (0/${urls.length})...`, 'spin');

        let totalBytes = 0;
        let unknownCount = 0;

        for (let i = 0; i < urls.length; i++) {
          const size = await getRemoteSizeBytes(urls[i]);

          if (typeof size === 'number' && isFinite(size)) {
            totalBytes += size;
            console.log(`${LOG_PREFIX} Size estimate ${i + 1}/${urls.length}: ${(size / 1024 / 1024).toFixed(2)} MB`);
          } else {
            unknownCount++;
            console.warn(`${LOG_PREFIX} Size unknown for image ${i + 1}/${urls.length}`);
          }

          updateIconWithAnimation(btn, '🧮', `Estimating total size (${i + 1}/${urls.length})...`, 'spin');
        }

        // Decision:
        // - strict: split only if estimated total > limit
        // - if sizes are unknown, we still DON'T force split automatically (keeps your original intent).
        const splitMode = totalBytes > MAX_PART_BYTES;

        if (unknownCount) {
          console.warn(`${LOG_PREFIX} ⚠️ ${unknownCount} image(s) without size info. Total is a partial estimate.`);
        }

        const totalMB = (totalBytes / 1024 / 1024).toFixed(1);
        updateIconWithAnimation(
          btn,
          '✅',
          splitMode ? `Total ~${totalMB}MB → SPLIT` : `Total ~${totalMB}MB → Single ZIP`,
          null
        );

        await new Promise(r => setTimeout(r, 400));

        // 3) Download & zip
        let batchFiles = {};
        let batchBytes = 0;
        let part = 1;
        let okCount = 0;
        let failCount = 0;

        function flushBatch() {
          const keys = Object.keys(batchFiles);
          if (!keys.length) return;

          const zipped = fflate.zipSync(batchFiles);
          const blob = new Blob([zipped], { type: 'application/zip' });

          const filename = splitMode
            ? `${zipBaseName}.part${pad3(part)}.zip`
            : `${zipBaseName}.zip`;

          triggerDownloadBlob(filename, blob);

          console.log(`${LOG_PREFIX} ✅ Created ${filename} (${(blob.size / 1024 / 1024).toFixed(2)}MB)`);

          batchFiles = {};
          batchBytes = 0;
          if (splitMode) part++;
        }

        updateIconWithAnimation(btn, '🌀', 'Downloading images...', 'spin');

        for (let i = 0; i < urls.length; i++) {
          const finalUrl = urls[i];

          const rawName = (finalUrl.split('/').pop() || '');
          const baseName = rawName.split('?')[0];
          const ext = baseName.includes('.') ? baseName.split('.').pop().toLowerCase() : 'jpg';
          const filename = `${generateRandomHex()}.${ext}`;

          try {
            const { blob } = await downloadImage(finalUrl);
            if (!blob || blob.size === 0) { failCount++; continue; }

            console.log(`${LOG_PREFIX} Downloaded blob: ${(blob.size / 1024 / 1024).toFixed(2)} MB`);

            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');
                } else {
                  uint8 = await blobToUint8Array(blob);
                }
              } catch (e) {
                console.warn(`${LOG_PREFIX} Transparency check failed for ${filename}`, e);
                uint8 = await blobToUint8Array(blob);
              }
            } else {
              uint8 = await blobToUint8Array(blob);
            }

            console.log(`${LOG_PREFIX} Final image size: ${(uint8.byteLength / 1024 / 1024).toFixed(2)} MB`);

            if (splitMode && batchBytes + uint8.byteLength > MAX_PART_BYTES && Object.keys(batchFiles).length) {
              flushBatch();
            }

            batchFiles[finalFilename] = uint8;
            batchBytes += uint8.byteLength;
            okCount++;

            updateIconWithAnimation(
              btn,
              '📥',
              splitMode
                ? `Downloaded ${okCount}/${urls.length} • part ${pad3(part)} ~${Math.round(batchBytes / 1024 / 1024)}MB`
                : `Downloaded ${okCount}/${urls.length} • zip ~${Math.round(batchBytes / 1024 / 1024)}MB`,
              'pulse'
            );

          } catch (e) {
            failCount++;
            console.warn(`${LOG_PREFIX} Failed to download ${finalUrl}`, e);
          }
        }

        // flush final
        if (Object.keys(batchFiles).length) flushBatch();

        console.log(
          `${LOG_PREFIX} Done: ${okCount} image(s), estimated total ~${(totalBytes / 1024 / 1024).toFixed(2)} MB, ` +
          `split=${splitMode}, unknownSizes=${unknownCount}`
        );

        updateIconWithAnimation(
          btn,
          '✅',
          splitMode
            ? `${okCount} images saved in ${part - 1} ZIP part(s)`
            : `${okCount} images saved in 1 ZIP`,
          null
        );

        if (failCount) console.warn(`${LOG_PREFIX} Done with ${failCount} failure(s).`);

      } catch (e) {
        console.error(`${LOG_PREFIX} ❌ Fatal error:`, e);
        alert('Download failed. Check console for details.');
      } finally {
        setTimeout(() => {
          document.getElementById('patreon-lightbox-mask')?.remove();
          console.log(`${LOG_PREFIX} 🧼 Lightbox mask removed`);
          updateIconWithAnimation(btn, '📦', 'Download all post images', null);
          btn.disabled = false;
        }, 1500);
      }
    });
  }

  // =========================
  // INJECT BUTTON
  // =========================
  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<br>(Configurable ${MAX_PART_MB}MB ZIP parts)</div>
          `;
          // Tooltip short + helpful
          btn.title = `Downloads images as ZIP file(s). Parts are limited to ${MAX_PART_MB}MB (configurable in your userscript manager).`;

          h1.appendChild(btn);
          addDownloadButton(btn);
        }
        return true;
      }
      return false;
    };

    let attempts = 0;
    const interval = setInterval(() => {
      if (tryInject() || ++attempts >= retries) clearInterval(interval);
    }, 300);
  }

  waitForTitleAndInjectButton();

  // =========================
  // BACK TO TOP
  // =========================
  const topBtn = document.createElement('button');
  topBtn.id = 'top';
  topBtn.setAttribute('aria-label', 'Scroll to top');
  topBtn.setAttribute('title', 'Scroll to top');
  setButtonContent(topBtn, '🔝', '');
  document.body.appendChild(topBtn);

  window.addEventListener('scroll', () => {
    const show = document.body.scrollTop > 20 || document.documentElement.scrollTop > 20;
    topBtn.style.display = show ? 'block' : 'none';
  });

  topBtn.addEventListener('click', () => window.scrollTo({ top: 0, behavior: 'smooth' }));
})();