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