Sleazy Fork is available in English.
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.
// ==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();
})();