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.

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

Advertisement:

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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