Patreon Post Images Downloader

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

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

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

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

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

您需要先安装一个扩展,例如 篡改猴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'));
        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' });
    }
})();