Patreon Post Images Downloader

Adds a button to download all Patreon post images as a ZIP archive

当前为 2025-09-21 提交的版本,查看 最新版本

您需要先安装一个扩展,例如 篡改猴Greasemonkey暴力猴,之后才能安装此脚本。

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

您需要先安装一个扩展,例如 篡改猴暴力猴,之后才能安装此脚本。

您需要先安装一个扩展,例如 篡改猴Userscripts ,之后才能安装此脚本。

您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。

您需要先安装用户脚本管理器扩展后才能安装此脚本。

(我已经安装了用户脚本管理器,让我安装!)

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展,比如 Stylus,才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

您需要先安装一款用户样式管理器扩展后才能安装此样式。

(我已经安装了用户样式管理器,让我安装!)

// ==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, .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;

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