Pawchive Expand All

Adds "Expand All" and "Download All" buttons. Expands thumbnails to full images in a smooth queue with retries; downloads originals sequentially in gallery order with proper numbered filenames.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Pawchive Expand All
// @namespace    pawchive-expand-all
// @version      1.2
// @description  Adds "Expand All" and "Download All" buttons. Expands thumbnails to full images in a smooth queue with retries; downloads originals sequentially in gallery order with proper numbered filenames.
// @author       BlueAnivia
// @match        https://pawchive.pw/*
// @match        https://*.pawchive.pw/*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=pawchive.pw
// @run-at       document-idle
// @license      MIT
// @grant        GM_xmlhttpRequest
// @connect      pawchive.pw
// @connect      *
// ==/UserScript==

(function () {
  'use strict';

  // ---------- config ----------
  const CONCURRENCY = 3;      // how many full images load at once (expand)
  const MAX_RETRIES = 4;      // retry attempts per image
  const RETRY_DELAY = 1500;   // ms base delay between retries (doubles each attempt)
  const LOAD_TIMEOUT = 30000; // ms before a stalled load counts as failed
  const DL_GAP = 400;         // ms pause between saved files (keeps browser download order stable)

  // ---------- state ----------
  let expanded = false;
  let queue = [];
  let active = 0;
  let done = 0;
  let total = 0;
  let btn = null;

  let downloading = false;
  let dlBtn = null;

  // ---------- UI ----------
  const BTN_BASE = {
    position: 'fixed',
    right: '20px',
    zIndex: '99999',
    padding: '10px 16px',
    font: '600 14px/1 system-ui, sans-serif',
    color: '#fff',
    background: '#4a5568',
    border: 'none',
    borderRadius: '8px',
    cursor: 'pointer',
    boxShadow: '0 2px 8px rgba(0,0,0,.35)',
  };

  function styleButton(b) {
    Object.assign(b.style, BTN_BASE);
    b.addEventListener('mouseenter', () => (b.style.background = '#2d3748'));
    b.addEventListener('mouseleave', () => (b.style.background = '#4a5568'));
  }

  function ensureButton() {
    if (!btn || !document.body.contains(btn)) {
      btn = document.createElement('button');
      btn.id = 'pawchive-expand-all-btn';
      btn.textContent = 'Expand all';
      styleButton(btn);
      btn.style.bottom = '64px';
      btn.addEventListener('click', () => {
        if (!expanded) expandAll();
        else collapseAll();
      });
      document.body.appendChild(btn);
      setProgress();
    }
    if (!dlBtn || !document.body.contains(dlBtn)) {
      dlBtn = document.createElement('button');
      dlBtn.id = 'pawchive-download-all-btn';
      dlBtn.textContent = 'Download all';
      styleButton(dlBtn);
      dlBtn.style.bottom = '20px';
      dlBtn.addEventListener('click', () => {
        if (!downloading) downloadAll();
      });
      document.body.appendChild(dlBtn);
    }
  }

  function setProgress() {
    if (!btn) return;
    if (!expanded) {
      btn.textContent = 'Expand all';
    } else if (done < total) {
      btn.textContent = `Loading ${done}/${total}…`;
    } else {
      btn.textContent = 'Collapse all';
    }
  }

  // ---------- helpers ----------
  function getThumbLinks() {
    return [...document.querySelectorAll('a.fileThumb.image-link')].filter((a) =>
      /\.(jpe?g|png|gif|webp|avif)(\?|$)/i.test(a.href)
    );
  }

  // -- gallery title -> safe filename base --
  function getGalleryTitle() {
    const h1 = document.querySelector('h1.post__title');
    let text = '';
    if (h1) {
      const spans = [...h1.querySelectorAll('span')];
      text = spans.length
        ? spans.map((s) => s.textContent.trim()).filter(Boolean).join(' ')
        : h1.textContent;
    }
    text = (text || document.title || 'gallery').replace(/\s+/g, ' ').trim();
    // strip characters that are illegal or troublesome in filenames
    text = text
      .replace(/[\\/:*?"<>|]/g, '-')  // Windows-illegal chars
      .replace(/[\u0000-\u001f]/g, '') // control chars
      .replace(/\s*-\s*/g, ' - ')
      .replace(/\s+/g, ' ')
      .trim()
      .replace(/[. ]+$/, '');          // no trailing dots/spaces
    if (text.length > 150) text = text.slice(0, 150).trim();
    return text || 'gallery';
  }

  function getExtension(url) {
    const m = url.match(/\.(jpe?g|png|gif|webp|avif)(?:\?|$)/i);
    return m ? m[1].toLowerCase().replace('jpeg', 'jpg') : 'jpg';
  }

  function padNum(n, width) {
    return String(n).padStart(width, '0');
  }

  // ---------- expand: load with retry ----------
  function loadWithRetry(anchor, attempt = 0) {
    return new Promise((resolve) => {
      const img = new Image();
      let settled = false;

      const timer = setTimeout(() => {
        if (settled) return;
        settled = true;
        img.src = '';
        fail();
      }, LOAD_TIMEOUT);

      function fail() {
        clearTimeout(timer);
        if (attempt < MAX_RETRIES) {
          const delay = RETRY_DELAY * Math.pow(2, attempt);
          setTimeout(() => loadWithRetry(anchor, attempt + 1).then(resolve), delay);
        } else {
          resolve(null);
        }
      }

      img.onload = () => {
        if (settled) return;
        settled = true;
        clearTimeout(timer);
        resolve(img);
      };
      img.onerror = () => {
        if (settled) return;
        settled = true;
        fail();
      };

      img.src = attempt === 0
        ? anchor.href
        : anchor.href + (anchor.href.includes('?') ? '&' : '?') + '_r=' + attempt;
    });
  }

  function pump() {
    while (active < CONCURRENCY && queue.length) {
      const anchor = queue.shift();
      active++;
      loadWithRetry(anchor).then((img) => {
        active--;
        done++;
        // anchor may have been removed by SPA navigation while loading
        if (img && expanded && document.body.contains(anchor)) {
          const thumb = anchor.querySelector('img[data-src]');
          if (thumb) thumb.style.display = 'none';
          img.style.maxWidth = '100%';
          img.className = 'expanded-full';
          img.style.opacity = '0';
          img.style.transition = 'opacity .25s';
          anchor.appendChild(img);
          requestAnimationFrame(() => (img.style.opacity = '1'));
          anchor.addEventListener('click', collapseClick);
        }
        setProgress();
        pump();
      });
    }
  }

  function collapseClick(e) {
    const anchor = e.currentTarget;
    const full = anchor.querySelector('img.expanded-full');
    if (full) {
      e.preventDefault();
      e.stopPropagation();
      full.remove();
      const thumb = anchor.querySelector('img[data-src]');
      if (thumb) thumb.style.display = '';
      anchor.removeEventListener('click', collapseClick);
    }
  }

  function expandAll() {
    const links = getThumbLinks().filter((a) => !a.querySelector('img.expanded-full'));
    queue = links;
    total = links.length;
    done = 0;
    expanded = true;
    setProgress();
    pump();
  }

  function collapseAll() {
    expanded = false;
    queue = [];
    total = 0;
    done = 0;
    getThumbLinks().forEach((a) => {
      const full = a.querySelector('img.expanded-full');
      if (full) full.remove();
      const thumb = a.querySelector('img[data-src]');
      if (thumb) thumb.style.display = '';
      a.removeEventListener('click', collapseClick);
    });
    setProgress();
  }

  // ---------- download all ----------

  // Fetch the ORIGINAL file (anchor.href = full-size source, not the thumbnail).
  // Prefers GM_xmlhttpRequest (works cross-origin for CDN/image subdomains),
  // falls back to fetch() if the grant isn't available.
  function fetchBlob(url) {
    if (typeof GM_xmlhttpRequest === 'function') {
      return new Promise((resolve, reject) => {
        GM_xmlhttpRequest({
          method: 'GET',
          url,
          responseType: 'blob',
          timeout: LOAD_TIMEOUT,
          onload: (r) => {
            if (r.status >= 200 && r.status < 300 && r.response) resolve(r.response);
            else reject(new Error('HTTP ' + r.status));
          },
          onerror: () => reject(new Error('network error')),
          ontimeout: () => reject(new Error('timeout')),
        });
      });
    }
    return fetch(url, { credentials: 'include' }).then((r) => {
      if (!r.ok) throw new Error('HTTP ' + r.status);
      return r.blob();
    });
  }

  async function fetchBlobWithRetry(url) {
    for (let attempt = 0; attempt <= MAX_RETRIES; attempt++) {
      const u = attempt === 0
        ? url
        : url + (url.includes('?') ? '&' : '?') + '_r=' + attempt;
      try {
        return await fetchBlob(u);
      } catch (e) {
        if (attempt === MAX_RETRIES) throw e;
        await new Promise((res) => setTimeout(res, RETRY_DELAY * Math.pow(2, attempt)));
      }
    }
    throw new Error('unreachable');
  }

  function saveBlob(blob, filename) {
    const url = URL.createObjectURL(blob);
    const a = document.createElement('a');
    a.href = url;
    a.download = filename;
    a.style.display = 'none';
    document.body.appendChild(a);
    a.click();
    a.remove();
    setTimeout(() => URL.revokeObjectURL(url), 30000);
  }

  async function downloadAll() {
    const links = getThumbLinks(); // DOM order == gallery order
    if (!links.length) {
      dlBtn.textContent = 'Nothing to download';
      setTimeout(() => (dlBtn.textContent = 'Download all'), 2000);
      return;
    }

    downloading = true;
    const title = getGalleryTitle();
    const width = Math.max(2, String(links.length).length); // 01, 02… or 001…
    let failed = 0;

    // Strictly sequential: guarantees files hit the disk in gallery order
    // and the page number in each filename matches its position.
    for (let i = 0; i < links.length; i++) {
      dlBtn.textContent = `Downloading ${i + 1}/${links.length}…`;
      const href = links[i].href; // original full-quality file
      const filename = `${title} - ${padNum(i + 1, width)}.${getExtension(href)}`;
      try {
        const blob = await fetchBlobWithRetry(href);
        saveBlob(blob, filename);
      } catch (e) {
        failed++;
        console.warn(`[Pawchive DL] failed: ${filename}`, e);
      }
      await new Promise((res) => setTimeout(res, DL_GAP));
    }

    downloading = false;
    dlBtn.textContent = failed
      ? `Done (${failed} failed)`
      : 'Done!';
    setTimeout(() => {
      if (!downloading) dlBtn.textContent = 'Download all';
    }, 4000);
  }

  // reset state when we land on a new page/gallery
  function onNavigate() {
    expanded = false;
    queue = [];
    total = 0;
    done = 0;
    downloading = false;
    ensureButton();
    if (dlBtn) dlBtn.textContent = 'Download all';
  }

  // ---------- navigation handling ----------

  // 1. SPA navigation: hook pushState/replaceState + popstate (back/forward)
  let lastUrl = location.href;
  function checkUrl() {
    if (location.href !== lastUrl) {
      lastUrl = location.href;
      // give the SPA a moment to render the new gallery
      setTimeout(onNavigate, 300);
    }
  }

  const origPush = history.pushState;
  history.pushState = function (...args) {
    origPush.apply(this, args);
    checkUrl();
  };
  const origReplace = history.replaceState;
  history.replaceState = function (...args) {
    origReplace.apply(this, args);
    checkUrl();
  };
  window.addEventListener('popstate', checkUrl);

  // 2. bfcache: page restored from back-forward cache without re-running scripts
  window.addEventListener('pageshow', (e) => {
    if (e.persisted) onNavigate();
    else ensureButton();
  });

  // 3. safety net: if the site rebuilds <body> content and nukes the buttons,
  //    a light observer puts them back (also catches URL changes some routers do silently)
  const observer = new MutationObserver(() => {
    checkUrl();
    if (
      !document.getElementById('pawchive-expand-all-btn') ||
      !document.getElementById('pawchive-download-all-btn')
    ) {
      ensureButton();
    }
  });
  observer.observe(document.body, { childList: true, subtree: false });

  ensureButton();
})();