iStripper downloader

Adds a convenience download button for owned image packs using the official ZIP link, includes the public full-size cover, and repacks the archive with a cleaner structure.

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!)

Advertisement:

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!)

Advertisement:

// ==UserScript==
// @name         iStripper downloader
// @description  Adds a convenience download button for owned image packs using the official ZIP link, includes the public full-size cover, and repacks the archive with a cleaner structure.
// @version      1.0.2
// @author       BreatFR
// @namespace    http://usercssjs.breat.fr/i/istripper
// @match        *://*.istripper.com/*
// @require      https://cdn.jsdelivr.net/npm/[email protected]/umd/index.min.js
// @copyright    2025, BreatFR (https://breat.fr)
// @icon         https://www.istripper.com/v2/apple-touch-icon.png
// @license      AGPL-3.0-or-later; https://www.gnu.org/licenses/agpl-3.0.txt
// @grant        GM_download
// @grant        GM_xmlhttpRequest
// @run-at       document-end
//
// ==/UserScript==

(function() {
  'use strict';

  const style = document.createElement('style');
  style.textContent = `
    .istripper-download-btn {
      align-items: center;
      background-color: rgba(24, 24, 24, .2);
      border: none;
      border-radius: .5em;
      color: #fff;
      cursor: pointer;
      display: flex;
      flex-direction: column;
      font-family: poppins, cursive;
      font-size: 1.15rem;
      gap: 1em;
      justify-self: center;
      line-height: 1.5;
      margin-top: 1em;
      padding: .5em 1em;
      position: absolute;
      transition: background-color .3s ease, box-shadow .3s ease;
      z-index: 9999;
    }

    .istripper-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);
      }
    }

    .istripper-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;
      }
    }

    .istripper-btn-icon.pulse {
      animation: pulseLoop 1.8s ease-in-out infinite;
    }

    .istripper-btn-icon {
      font-size: 3em;
      line-height: 1em;
    }

    /* Notifications discrètes */
    .istripper-toast {
      position: fixed;
      bottom: 20px;
      right: 20px;
      background: rgba(24, 24, 24, 0.95);
      color: #fff;
      padding: 12px 16px;
      border-radius: 6px;
      box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
      z-index: 999999;
      font-family: Poppins, sans-serif;
      font-size: 1.2rem;
      max-width: 400px;
      word-break: break-word;
      opacity: 0;
      transform: translateY(20px);
      transition: all 0.3s ease;
      border-left: 4px solid #40c057;
    }

    .istripper-toast.show {
      opacity: 1;
      transform: translateY(0);
    }

    .istripper-toast.error {
      border-left-color: #fa5252;
    }

    .istripper-toast.info {
      border-left-color: #339af0;
    }
  `;
  document.head.appendChild(style);

  // 🔔 Fonction pour afficher une notification discrète
  function showNotification(message, type = 'success', duration = 2500) {
    const toast = document.createElement('div');
    toast.className = 'istripper-toast' + (type === 'error' ? ' error' : type === 'info' ? ' info' : '');
    toast.textContent = message;
    document.body.appendChild(toast);

    requestAnimationFrame(() => {
      toast.classList.add('show');
    });

    setTimeout(() => {
      toast.classList.remove('show');
      setTimeout(() => toast.remove(), 300);
    }, duration);
  }

  function updateCoverImage() {
    const images = document.querySelectorAll('img.w-full.h-full.object-cover');
    images.forEach(img => {
      if (!img.src.includes("_card_full")) img.src = img.src.replace("cardsoft_2x.jpg", "card_full.jpg");
    });
  }

  updateCoverImage();
  setInterval(updateCoverImage, 500);

  // ===== Helpers UI =====
  function setButtonContent(btn, icon, label) {
    let iconEl = btn.querySelector('.istripper-btn-icon');
    let labelEl = btn.querySelector('.istripper-btn-label');

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

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

  function setIconAnimation(btn, type) {
    const icon = btn.querySelector('.istripper-btn-icon');
    if (!icon) return;
    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 resetDownloadButton(btn) {
    btn.disabled = false;
    updateIconWithAnimation(btn, '📦', 'Download pack + cover', null);
  }

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

  // ===== Binary download via GM (avoids CORS issues) =====
  function gmGetArrayBuffer(url) {
    return new Promise((resolve, reject) => {
      GM_xmlhttpRequest({
        method: "GET",
        url,
        responseType: "arraybuffer",
        onload: (res) => {
          if (res.status >= 200 && res.status < 300 && res.response) resolve(res.response);
          else reject(new Error(`HTTP ${res.status} for ${url}`));
        },
        onerror: () => reject(new Error(`Network error for ${url}`)),
      });
    });
  }

  async function gmGetUint8(url) {
    const ab = await gmGetArrayBuffer(url);
    return new Uint8Array(ab);
  }

  // ===== ZIP logic: unzip -> flatten photos/ -> add cover -> rezip =====
  function stripPhotosFolder(entries) {
    const out = {};
    for (const [path, data] of Object.entries(entries)) {
      if (!data || !data.length) continue;
      out[path.replace(/^photos\//i, "")] = data;
    }
    return out;
  }

  function ensureUniqueName(map, desired) {
    if (!map[desired]) return desired;
    const dot = desired.lastIndexOf(".");
    const base = dot >= 0 ? desired.slice(0, dot) : desired;
    const ext  = dot >= 0 ? desired.slice(dot) : "";
    let i = 2;
    while (map[`${base}_${i}${ext}`]) i++;
    return `${base}_${i}${ext}`;
  }

  function getOfficialZipUrl() {
    const link = document.querySelector('a[href^="https://www.istripper.com/fileaccess/zip/"]');
    if (link?.href && /\/fileaccess\/zip\/[a-z]\d+(\b|\/|$)/i.test(link.href)) return link.href;
    return null;
  }

  async function rebuildZipFlatWithCover({ zipUrl, coverUrl, zipNameBase, btn }) {
    updateIconWithAnimation(btn, '🌀', 'Downloading official ZIP...', 'spin');
    const zipUint8 = await gmGetUint8(zipUrl);

    updateIconWithAnimation(btn, '📦', 'Unzipping...', 'pulse');
    const entries = await new Promise((resolve, reject) => {
      fflate.unzip(zipUint8, (err, data) => (err ? reject(err) : resolve(data)));
    });

    const flat = stripPhotosFolder(entries);

    updateIconWithAnimation(btn, '🖼️', 'Downloading cover...', 'pulse');
    const coverUint8 = await gmGetUint8(coverUrl);
    const coverName = ensureUniqueName(flat, 'cover.jpg');
    flat[coverName] = coverUint8;

    updateIconWithAnimation(btn, '📦', `Creating ZIP (${Object.keys(flat).length} files)...`, null);
    const newZip = await new Promise((resolve, reject) => {
      fflate.zip(flat, { level: 0 }, (err, data) => (err ? reject(err) : resolve(data)));
    });

    const zipName = sanitizeZipName(zipNameBase) + ".zip";
    const blobUrl = URL.createObjectURL(new Blob([newZip], { type: "application/zip" }));

    updateIconWithAnimation(btn, '📥', 'Saving ZIP...', 'pulse');
    GM_download({
      url: blobUrl,
      name: zipName,
      saveAs: true,
      onload: () => setTimeout(() => URL.revokeObjectURL(blobUrl), 30_000),
      onerror: (err) => {
        URL.revokeObjectURL(blobUrl);
        console.error('[iStripper Collector] ❌ GM_download failed:', err);
        alert('ZIP download failed.');
      }
    });
  }

  // ===== Download button injection =====
  function addDownloadButton() {
    const officialZipLink = document.querySelector('a[href^="https://www.istripper.com/fileaccess/zip/"]');
    const coverImg = document.querySelector('img.w-full.h-full.object-cover');

    const isPackPage = !!officialZipLink && !!coverImg;
    if (!isPackPage) return;

    if (document.querySelector('.istripper-download-btn')) return;

    console.log('[iStripper Collector] Injecting download button');
    const btn = document.createElement('button');
    btn.className = 'istripper-download-btn';
    btn.innerHTML = `
      <div class="istripper-btn-icon">📦</div>
      <div class="istripper-btn-label">Download pack + cover</div>
    `;

    const showRoot = document.querySelector('main .relative.w-full');
    const blackBox = showRoot?.querySelector('div.relative.overflow-hidden.rounded-2xl[style*="rgba(210, 4, 45, 0.3)"]');

    if (blackBox) {
      blackBox.style.overflow = 'visible';
      blackBox.appendChild(btn);
    }

    btn.addEventListener('click', async () => {
      btn.disabled = true;

      try {
        const zipUrl = getOfficialZipUrl();
        if (!zipUrl) throw new Error('Official ZIP link not found.');

        const coverImg = blackBox?.querySelector('img[src*="card_full.jpg"]') || blackBox?.querySelector('img.w-full.h-full.object-cover');

        if (!coverImg?.src) throw new Error('Cover image not found.');

        const coverUrl = coverImg.src;

        const h1 = blackBox?.querySelector('h1');
        const zipNameBase = h1 ? h1.textContent : 'istripper_pack';

        await rebuildZipFlatWithCover({ zipUrl, coverUrl, zipNameBase, btn });

        updateIconWithAnimation(btn, '✅', 'ZIP ready', null);
      } catch (e) {
        console.error('[iStripper Collector] ❌ Failed:', e);
        alert(`Failed: ${e?.message || e}`);

        // (plus besoin de remettre "📦 Download..." ici, le finally reset tout)
        updateIconWithAnimation(btn, '❌', 'Failed', null);
      } finally {
        // Laisse le user voir "ZIP ready" (ou "Failed") 2s, puis reset état initial
        setTimeout(() => resetDownloadButton(btn), 2000);
      }
    });
  }

  addDownloadButton();
  setInterval(addDownloadButton, 1000);

  // ==== Improve VR download by copying clean MP4 filename ====
  function sanitizeFileNamePart(str) {
    return (str || "")
      .trim()
      .replace(/[\x00-\x1f\x80-\x9f]/g, "")
      .replace(/[\\/:*?"<>|]/g, "_")
      .replace(/\s+/g, " ")
      .slice(0, 180);
  }

  function getVrShowFileName() {
    const h1 = document.querySelector("h1");
    const title = sanitizeFileNamePart(h1?.textContent || "iStripper VR");

    const subtitle =
      h1?.parentElement?.querySelector("p.font-instrument-serif") ||
      h1?.parentElement?.querySelector("p.italic") ||
      [...document.querySelectorAll("h1 ~ p")]
        .find(p => p.textContent?.trim());

    const subtitleText = sanitizeFileNamePart(subtitle?.textContent || "");

    return subtitleText
      ? `${title} - ${subtitleText}.mp4`
      : `${title}.mp4`;
  }

  function patchVrLinks() {
    const links = document.querySelectorAll('a[href*="/fileaccess/vr180/"]');

    links.forEach(link => {
      const filename = getVrShowFileName();

      link.dataset.vrPatched = "1";
      link.setAttribute("download", filename);
      link.download = filename;
      link.title = `Download as: ${filename}`;
    });
  }

  async function copyTextToClipboard(text) {
    try {
      await navigator.clipboard.writeText(text);
      return true;
    } catch (e) {
      console.warn("[iStripper Collector] Clipboard API failed:", e);
      return false;
    }
  }

  patchVrLinks();
  setInterval(patchVrLinks, 1000);

  document.addEventListener("click", async (event) => {
    const link = event.target.closest('a[href*="/fileaccess/vr180/"]');
    if (!link) return;

    const filename = link.download || link.getAttribute("download") || getVrShowFileName();

    const copied = await copyTextToClipboard(filename);

    console.log(
      copied
        ? "[iStripper Collector] VR filename copied:"
        : "[iStripper Collector] VR filename copy failed:",
      filename
    );

    // 🔔 Notification discrète pour VR
    if (copied) {
      showNotification('✅ Title copied', 'success', 2000);
    } else {
      showNotification('❌ Copy failed', 'error', 2500);
    }
  }, true);
})();