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.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Advertisement:

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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