FANBOX Post Images Downloader

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

// ==UserScript==
// @name         FANBOX Post Images Downloader
// @description  Adds a button to download all FANBOX post images as a ZIP archive
// @version      1.0.3
// @author       BreatFR
// @namespace    http://gitlab.com/breatfr
// @match        *://*.fanbox.cc/*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/umd/index.min.js
// @copyright    2025, BreatFR (https://breat.fr)
// @icon         https://s.pximg.net/common/images/fanbox/apple-touch-icon.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('[FANBOX Post Images Downloader] Script loaded');

    const style = document.createElement('style');
    style.textContent = `
        .fanbox-download-btn {
            align-items: center;
            background-color: rgba(24, 24, 24, .2);
            border: none;
            border-radius: .5em;
            bottom: 1em;
            color: #fff;
            cursor: pointer;
            display: flex;
            flex-direction: column;
            font-family: poppins, cursive;
            font-size: 1.15rem;
            gap: 1em;
            justify-content: center;
            left: 1em;
            line-height: 1.5;
            padding: .5em 1em;
            position: fixed;
            transition: background-color .3s ease, box-shadow .3s ease;
            z-index: 9999;
        }
        .fanbox-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); }
        }
        .fanbox-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; }
        }
        .fanbox-btn-icon.pulse {
            animation: pulseLoop 1.8s ease-in-out infinite;
        }
        .fanbox-btn-icon {
            font-size: 3em;
            line-height: 1em;
        }
        #top {
            bottom: 1em;
            font-size: 1.2em !important;
            position: fixed;
            right: 1em;
        }
    `;
    document.head.appendChild(style);

    // Clean images links
    function cleanFanboxDownloadLink(url) {
        return url.replace(
            /https:\/\/downloads\.fanbox\.cc\/images\/post\/(\d+)\/w\/1200\/([^/]+)$/,
            'https://downloads.fanbox.cc/images/post/$1/$2'
        );
    }

    (function () {
      const selector = 'div[class*="Cover__CoverImage"]';

      function cleanPixivCoverUrl(url) {
        return url.replace(
          /https:\/\/pixiv\.pximg\.net\/c\/[^/]+\/(fanbox\/public\/images\/post\/\d+\/cover\/[^.]+\.\w+)/,
          'https://pixiv.pximg.net/$1'
        );
      }

      function transformCoverDiv(div) {
        if (!div || div.tagName !== 'DIV') return;

        const style = div.getAttribute('style');
        if (!style || !style.includes('url(')) {
          console.warn('[Fanbox Collector] ⏳ Style not ready yet...');
          return;
        }

        const match = style.match(/url\(["']?(.*?)["']?\)/);
        if (!match) return;

        const rawUrl = match[1];
        const cleanedUrl = cleanPixivCoverUrl(rawUrl);

        const link = document.createElement('a');
        link.href = cleanedUrl;
        link.target = '_blank';
        link.rel = 'noopener noreferrer';
        link.className = div.className;

        const img = document.createElement('img');
        img.src = cleanedUrl;
        img.alt = 'Cover';
        img.style.width = '100%';
        img.style.height = 'auto';
        img.style.display = 'block';

        link.appendChild(img);
        div.replaceWith(link);

        console.log('[Fanbox Collector] ✅ Cover div replaced with <a><img>:', cleanedUrl);
      }

      const observer = new MutationObserver(() => {
        const div = document.querySelector(selector);
        const alreadyTransformed = document.querySelector(`${selector} img`);
        if (div && !alreadyTransformed) {
          const style = div.getAttribute('style');
          if (style && style.includes('url(')) {
            transformCoverDiv(div);
            observer.disconnect();
          }
        }
      });

      observer.observe(document.body, {
        childList: true,
        subtree: true,
        attributes: true,
        attributeFilter: ['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('.fanbox-btn-icon');
      let labelEl = btn.querySelector('.fanbox-btn-label');

      if (!iconEl) {
        iconEl = document.createElement('div');
        iconEl.className = 'fanbox-btn-icon';
        btn.appendChild(iconEl);
      }
      if (!labelEl) {
        labelEl = document.createElement('div');
        labelEl.className = 'fanbox-btn-label';
        btn.appendChild(labelEl);
      }

      iconEl.textContent = icon;
      labelEl.textContent = label;
    }

    function setIconAnimation(btn, type) {
      const icon = btn.querySelector('.fanbox-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 forceScrollToLoadContent() {
      console.log('[Fanbox Collector] Starting auto-scroll to load lazy images');
      const step = 300;
      const delay = 500;
      const maxScroll = document.body.scrollHeight;
      for (let scrollY = 0; scrollY < maxScroll; scrollY += step) {
        window.scrollTo({ top: scrollY, behavior: 'smooth' });
        await new Promise(resolve => setTimeout(resolve, delay));
      }
      console.log('[Fanbox Collector] Scroll complete, waiting for final content');
      await new Promise(resolve => setTimeout(resolve, 1500));
    }

    function downloadImage(url) {
      console.log(`[Fanbox 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, filename: url.split('/').pop() });
            } else {
              reject(new Error(`Download failed or empty blob for ${url}`));
            }
          },
          onerror: function(err) {
            reject(err);
          }
        });
      });
    }

    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() {
      const cleanUrl = location.href.split('?')[0];
      const isPostPage = /^https:\/\/(?:www\.fanbox\.cc\/@[^/]+|[^.]+\.fanbox\.cc)\/posts\/\d+$/.test(cleanUrl);
      if (!isPostPage) return;

      console.log('[FANBOX Post Images Downloader] Injecting download button');
      const btn = document.createElement('button');
      btn.className = 'fanbox-download-btn';
      btn.innerHTML = `
        <div class="fanbox-btn-icon">📦</div>
        <div class="fanbox-btn-label">Download all post images</div>
      `;
      document.body.appendChild(btn);

      btn.addEventListener('click', async () => {
        console.log('[FANBOX Post Images Downloader] Button clicked');
        btn.disabled = true;
        updateIconWithAnimation(btn, '🌀', 'Scrolling to load images...', 'spin');

        await forceScrollToLoadContent();

        console.log('[FANBOX Post Images Downloader] Collecting image links...');
        const anchors = Array.from(document.querySelectorAll('a[href]'))
          .map(a => a.href)
          .map(href => {
            if (href.includes('downloads.fanbox.cc/images/post/')) {
              return cleanFanboxDownloadLink(href);
            }
            if (href.includes('pixiv.pximg.net/fanbox/public/images/post/')) {
              return href;
            }
            return null;
          })
          .filter(href => href && /\.(jpeg|jpg|png|webp|gif)$/i.test(href));

        console.log('[FANBOX Post Images Downloader] Final image list:', anchors);
        console.log(`[FANBOX Post Images Downloader] Found ${anchors.length} image links`);

        if (anchors.length === 0) {
          alert('No Fanbox image links found.');
          btn.disabled = false;
          updateIconWithAnimation(btn, '📦', 'Download all post images', null);
          return;
        }

        const files = {};
        let count = 0;

        for (const url of anchors) {
          const filename = url.split('/').pop();
            try {
              const { blob } = await downloadImage(url);
              if (!blob || blob.size === 0) {
                console.warn(`[FANBOX Post Images Downloader] Skipped empty blob: ${filename}`);
                continue;
              }

              let finalName = filename;
              let uint8;

              const ext = filename.split('.').pop().toLowerCase();
              if (ext === 'png') {
                try {
                  const img = await loadImageFromBlob(blob);
                  if (!hasTransparency(img)) {
                    const jpegDataUrl = convertToJPEG(img);
                    const jpegBlob = await (await fetch(jpegDataUrl)).blob();
                    uint8 = await blobToUint8Array(jpegBlob);
                    finalName = filename.replace(/\.png$/i, '.jpeg');
                    console.log(`[FANBOX Post Images Downloader] Converted PNG to JPEG: ${finalName}`);
                  } else {
                    uint8 = await blobToUint8Array(blob);
                    console.log(`[FANBOX Post Images Downloader] PNG with transparency kept: ${filename}`);
                  }
                } catch (e) {
                  console.warn(`[FANBOX Post Images Downloader] Transparency check failed for ${filename}`, e);
                  uint8 = await blobToUint8Array(blob);
                }
              } else {
                uint8 = await blobToUint8Array(blob);
              }

              // ✅ Harmonisation ici
              if (url.includes('pixiv.pximg.net/fanbox/public/images/post/')) {
                const extFinal = finalName.split('.').pop().toLowerCase();
                finalName = `cover.${extFinal === 'jpg' ? 'jpeg' : extFinal}`;
              }

              files[finalName] = uint8;
              console.log(`[FANBOX Post Images Downloader] Added to ZIP: ${finalName} (${uint8.length} bytes)`);
              count++;
              updateIconWithAnimation(btn, '📥', `Downloading image ${count}/${anchors.length}`, 'pulse');

            } catch (e) {
              if (e.message.includes('Quota reached')) {
                console.warn('[FANBOX Post Images Downloader] Quota reached, aborting download.');
                updateIconWithAnimation(btn, '⏳', 'Quota atteint, réessaye demain', null);
                break;
              }
              console.warn(`[FANBOX Post Images Downloader] Failed to download ${url}`, e);
            }
        }

        if (count === 0) {
          alert('All image downloads failed.');
          btn.disabled = false;
          updateIconWithAnimation(btn, '📦', 'Download all post images', null);
          return;
        }

        updateIconWithAnimation(btn, '📦', `Creating ZIP (${count} images)...`, null);

        try {
          console.log('[FANBOX Post Images Downloader] Compressing with fflate...');
          const zipped = fflate.zipSync(files);
          const blob = new Blob([zipped], { type: 'application/zip' });

          const titleElement = document.querySelector('h1[class*="styled__PostTitle-sc-"]');
          const zipName = titleElement ? titleElement.textContent.trim().replace(/[\\/:*?"<>|]/g, '_') : 'fanbox_images';

          console.log('[FANBOX Post Images Downloader] Triggering GM_download...');
          GM_download({
            url: URL.createObjectURL(blob),
            name: `${zipName}.zip`,
            saveAs: true,
            onerror: err => {
              console.error('[FANBOX Post Images Downloader] ❌ GM_download failed:', err);
              alert('ZIP download failed.');
            }
          });
        } catch (e) {
          console.error('[FANBOX Post Images Downloader] ❌ ZIP compression error:', e);
          alert('ZIP creation failed. Check console for details.');
        }

        updateIconWithAnimation(btn, '✅', `${count} images downloaded`, null);
        setTimeout(() => {
          updateIconWithAnimation(btn, '📦', 'Download all post images', null);
          btn.disabled = false;
        }, 3000);
      });
    }

    addDownloadButton();

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