Patreon Post Images Downloader

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 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' });
    }
})();