Auto-expand spoilers + remplace les miniatures Fastpic par les images full-size
// ==UserScript==
// @name pornolabFullSizeImages
// @namespace https://greasyfork.org/en/users/108513
// @version 3.4.2
// @description Auto-expand spoilers + remplace les miniatures Fastpic par les images full-size
// @author seb du 17
// @match https://pornolab.net/*
// @match http://pornolab.net/*
// @match https://*.pornolab.net/*
// @match http://*.pornolab.net/*
// @run-at document-idle
// @icon https://static.pornolab.net/favicon.ico
// @grant GM.xmlHttpRequest
// @connect fastpic.org
// @noframes
// @license MIT
// ==/UserScript==
/* jshint esversion: 11, browser: true, curly: true */
/* global GM, DOMParser */
(function () {
'use strict';
const CONFIG = {
SCAN_DEBOUNCE_MS: 100,
FETCH_TIMEOUT_MS: 5000,
RETRY_DELAY_MS: 3000,
MAX_RETRIES: 3,
MAX_CONCURRENT_REQUESTS: 6,
CACHE_SIZE: 150,
EXPAND_SPOILERS: true,
MAX_SPOILERS_CLICK: 1200,
SPOILER_CLICK_CHUNK: 25
};
const ANCHORS = 'a.postLink, a[href*="fastpic.org/"]';
const IMAGE_ATTRS = ['src', 'srcset', 'data-src', 'data-original'];
const imageStates = new WeakMap();
const urlCache = new Map(); // fullviewUrl -> bigUrl (LRU)
const jobs = new Map(); // fullviewUrl -> requête et retries partagés
const requestQueue = [];
const scanQueue = new Set();
const spoilerQueue = new Set();
const expandedSpoilers = new WeakSet();
let activeRequests = 0;
let scanTimer = null;
let spoilerScheduled = false;
let spoilerClicks = 0;
// ========== URLs et signatures ==========
function fastpicURL(raw, base) {
if (!raw) { return null; }
try {
const url = new URL(raw, base || location.href);
if (url.protocol !== 'https:' && url.protocol !== 'http:') { return null; }
if (url.hostname !== 'fastpic.org' && !url.hostname.endsWith('.fastpic.org')) { return null; }
if (url.username || url.password) { return null; }
return url;
} catch (e) { return null; }
}
function signedBigURL(raw, base) {
const url = fastpicURL(raw, base);
if (!url || !/^\/big\//i.test(url.pathname)) { return ''; }
for (const entry of url.searchParams) {
if (entry[0].toLowerCase() === 'md5' && entry[1]) { return url.href; }
}
return '';
}
function fullviewURL(raw) {
const url = fastpicURL(raw);
return url && /^\/fullview\/.+\.html$/i.test(url.pathname) ? url.href : '';
}
function thumbnailToBig(raw) {
const url = fastpicURL(raw);
if (!url || !/^\/thumb\//i.test(url.pathname)) { return ''; }
url.pathname = url.pathname.replace(/^\/thumb\//i, '/big/');
return signedBigURL(url.href);
}
function ensureNoht(raw) {
const url = new URL(raw);
if (url.searchParams.has('noht')) { return url.href; }
// Ne pas réencoder les paramètres existants : ils portent la signature.
const hashAt = url.href.indexOf('#');
const base = hashAt < 0 ? url.href : url.href.slice(0, hashAt);
const hash = hashAt < 0 ? '' : url.href.slice(hashAt);
const sep = base.indexOf('?') < 0 ? '?' : /[?&]$/.test(base) ? '' : '&';
return base + sep + 'noht=1' + hash;
}
function srcsetURLs(raw) {
return (raw || '').split(',').map(function(candidate) {
return candidate.trim().split(/\s+/)[0];
}).filter(Boolean);
}
function cacheGet(key) {
if (!urlCache.has(key)) { return ''; }
const value = urlCache.get(key);
urlCache.delete(key);
urlCache.set(key, value);
return value;
}
function cacheSet(key, value) {
urlCache.delete(key);
urlCache.set(key, value);
if (urlCache.size > CONFIG.CACHE_SIZE) {
urlCache.delete(urlCache.keys().next().value);
}
}
// ========== Extraction du lien /big/ ==========
function extractBigUrl(html, sourceUrl) {
if (!html) { return ''; }
try {
const doc = new DOMParser().parseFromString(html, 'text/html');
const candidates = doc.querySelectorAll('a[href], img[src], img[data-src], img[data-original], img[srcset], source[srcset]');
for (const el of candidates) {
const raws = [el.getAttribute('href'), el.getAttribute('data-original'),
el.getAttribute('data-src'), el.getAttribute('src')];
raws.push.apply(raws, srcsetURLs(el.getAttribute('srcset')));
for (const raw of raws) {
const candidate = signedBigURL(raw, sourceUrl);
if (candidate) { return candidate; }
}
}
} catch (e) {}
// URLs éventuellement placées dans un script au lieu d'un attribut HTML.
const normalized = html.replace(/\\\//g, '/').replace(/&/g, '&');
const matches = normalized.match(/https?:\/\/[^"'<>\s\\]+/gi) || [];
for (const raw of matches) {
const candidate = signedBigURL(raw, sourceUrl);
if (candidate) { return candidate; }
}
return '';
}
// ========== Identité des images et retour à la miniature ==========
function pictureSources(img) {
const picture = img.parentElement;
return picture && picture.tagName === 'PICTURE' ? Array.from(picture.querySelectorAll('source')) : [];
}
function imageSignature(img, anchor) {
return JSON.stringify([anchor.href, IMAGE_ATTRS.map(function(attr) { return img.getAttribute(attr); }),
pictureSources(img).map(function(el) { return [el.getAttribute('srcset'), el.getAttribute('media'), el.getAttribute('type')]; })]);
}
function isCurrent(state) {
return imageStates.get(state.img) === state && state.img.isConnected && state.anchor.isConnected &&
state.img.closest('a') === state.anchor && imageSignature(state.img, state.anchor) === state.signature;
}
function setOptimizedImageFlags(img) {
try { img.loading = 'lazy'; } catch (e) {}
try { img.decoding = 'async'; } catch (e) {}
try { img.fetchPriority = 'low'; } catch (e) {}
}
function saveImage(img) {
return {
attrs: IMAGE_ATTRS.map(function(attr) { return [attr, img.getAttribute(attr)]; }),
sources: pictureSources(img).map(function(el) { return [el, el.getAttribute('srcset')]; })
};
}
function restoreAttribute(el, attr, value) {
if (value === null) { el.removeAttribute(attr); }
else { el.setAttribute(attr, value); }
}
function applyBigUrl(state, bigUrl) {
if (!isCurrent(state)) { return; }
const img = state.img;
const finalUrl = ensureNoht(bigUrl);
if (state.cleanup) { state.cleanup(); }
function cleanup() {
img.removeEventListener('load', onLoad);
img.removeEventListener('error', onError);
}
function onLoad() {
if (isCurrent(state) && img.complete && img.naturalWidth > 0) { cleanup(); }
}
function onError() {
if (!isCurrent(state) || !img.complete || img.naturalWidth > 0) { return; }
cleanup();
state.original.attrs.forEach(function(entry) { restoreAttribute(img, entry[0], entry[1]); });
state.original.sources.forEach(function(entry) {
if (entry[0].parentElement === img.parentElement) { restoreAttribute(entry[0], 'srcset', entry[1]); }
});
state.signature = imageSignature(img, state.anchor);
state.status = 'failed';
if (state.fullview && urlCache.get(state.fullview) === bigUrl) { urlCache.delete(state.fullview); }
// Une conversion directe peut échouer ; tenter alors le lien HTML une fois.
if (state.fullview && !state.fullviewAttempted) { requestFullview(state.fullview, state); }
}
state.cleanup = cleanup;
state.finalUrl = finalUrl;
state.status = 'applied';
img.addEventListener('load', onLoad);
img.addEventListener('error', onError);
setOptimizedImageFlags(img);
img.removeAttribute('srcset');
pictureSources(img).forEach(function(el) { el.removeAttribute('srcset'); });
['data-src', 'data-original'].forEach(function(attr) {
if (img.hasAttribute(attr)) { img.setAttribute(attr, finalUrl); }
});
img.src = finalUrl;
state.signature = imageSignature(img, state.anchor);
}
// ========== Requêtes FireMonkey 3.x, six simultanées au maximum ==========
async function doXFetchText(url) {
if (typeof GM === 'undefined' || !GM || typeof GM.xmlHttpRequest !== 'function') {
return { html: '', retryable: false };
}
try {
const response = await GM.xmlHttpRequest({
method: 'GET', url: url, timeout: CONFIG.FETCH_TIMEOUT_MS, responseType: 'text'
});
if (!response) { return { html: '', retryable: true }; }
const status = Number(response.status) || 0;
if (status < 200 || status >= 300) {
return { html: '', retryable: status === 0 || status === 408 || status === 429 || status >= 500 };
}
return { html: response.responseText || (typeof response.response === 'string' ? response.response : '') || '', retryable: true };
} catch (e) {
return { html: '', retryable: true };
}
}
function pruneJob(job) {
job.images.forEach(function(state) {
if (!isCurrent(state)) {
job.images.delete(state);
if (imageStates.get(state.img) === state) { imageStates.delete(state.img); }
if (state.cleanup) { state.cleanup(); }
}
});
return job.images.size > 0;
}
function processRequestQueue() {
while (requestQueue.length && activeRequests < CONFIG.MAX_CONCURRENT_REQUESTS) {
const item = requestQueue.shift();
if (!pruneJob(item.job)) {
item.resolve({ html: '', retryable: false });
continue;
}
activeRequests++;
doXFetchText(item.job.url)
.then(item.resolve, function() { item.resolve({ html: '', retryable: true }); })
.finally(function() { activeRequests--; processRequestQueue(); });
}
}
function finishJob(job, bigUrl) {
if (jobs.get(job.url) === job) { jobs.delete(job.url); }
if (job.timer !== null) { clearTimeout(job.timer); }
const images = Array.from(job.images);
job.images.clear();
images.forEach(function(state) {
if (!isCurrent(state)) { return; }
if (bigUrl) { applyBigUrl(state, bigUrl); }
else { state.status = 'failed'; }
});
}
function runJob(job) {
job.timer = null;
if (!pruneJob(job)) { finishJob(job, ''); return; }
job.requesting = true;
new Promise(function(resolve) {
requestQueue.push({ job: job, resolve: resolve });
processRequestQueue();
}).then(function(result) {
job.requesting = false;
const bigUrl = extractBigUrl(result.html, job.url);
if (bigUrl) {
cacheSet(job.url, bigUrl);
finishJob(job, bigUrl);
} else if (!pruneJob(job) || !result.retryable || job.retries >= CONFIG.MAX_RETRIES) {
finishJob(job, '');
} else {
const delay = CONFIG.RETRY_DELAY_MS * Math.pow(2, job.retries++);
job.timer = setTimeout(function() { runJob(job); }, delay);
}
});
}
function requestFullview(url, state) {
state.fullviewAttempted = true;
state.status = 'pending';
const cached = cacheGet(url);
if (cached) { applyBigUrl(state, cached); return; }
const existing = jobs.get(url);
if (existing) { existing.images.add(state); return; }
const job = { url: url, images: new Set([state]), retries: 0, timer: null, requesting: false };
jobs.set(url, job);
runJob(job);
}
function pruneJobs() {
jobs.forEach(function(job) {
if (!pruneJob(job) && !job.requesting) { finishJob(job, ''); }
});
}
// ========== Traitement des images d'une ancre ==========
function processImage(anchor, img) {
const previous = imageStates.get(img);
if (previous && isCurrent(previous)) { return; }
if (previous && previous.cleanup) { previous.cleanup(); }
const href = anchor.href;
const fullview = fullviewURL(href);
let direct = signedBigURL(href);
// Un nouveau lien ne doit pas réutiliser la grande image du lien précédent.
const changedLink = previous && previous.href !== href && img.getAttribute('src') === previous.finalUrl;
if (!changedLink) {
const raws = [img.getAttribute('data-original'), img.getAttribute('data-src'),
img.currentSrc, img.getAttribute('src')];
raws.push.apply(raws, srcsetURLs(img.getAttribute('srcset')));
for (const raw of raws) {
if (previous && raw === previous.finalUrl) { continue; }
// La source courante peut être en retard après une modification de src.
if (raw === img.currentSrc && raw !== img.getAttribute('src') && !img.hasAttribute('srcset') && !pictureSources(img).length) { continue; }
const candidate = thumbnailToBig(raw) || signedBigURL(raw);
if (candidate) { direct = candidate; break; }
}
}
if (!direct && !fullview) { return; }
const state = {
img: img, anchor: anchor, href: href, fullview: fullview,
signature: imageSignature(img, anchor), original: saveImage(img),
status: 'pending', finalUrl: '', fullviewAttempted: false, cleanup: null
};
imageStates.set(img, state);
setOptimizedImageFlags(img);
if (direct) { applyBigUrl(state, direct); }
else { requestFullview(fullview, state); }
}
function processAnchor(anchor) {
if (!anchor || !anchor.isConnected) { return; }
let images = anchor.querySelectorAll('img.postImg');
if (!images.length) { images = anchor.querySelectorAll('img'); }
for (const img of images) { processImage(anchor, img); }
}
function scanRoot(root) {
if (!root || root.nodeType !== 1 || !root.isConnected) { return; }
const parent = root.closest(ANCHORS);
if (parent) { processAnchor(parent); }
const anchors = root.querySelectorAll(ANCHORS);
for (const anchor of anchors) { processAnchor(anchor); }
}
// ========== Spoilers : une seule file, 25 clics par frame, 1200 au total ==========
function stepSpoilers() {
spoilerScheduled = false;
let count = 0;
for (const head of spoilerQueue) {
if (count++ >= CONFIG.SPOILER_CLICK_CHUNK) { break; }
spoilerQueue.delete(head);
if (!head.isConnected || !head.matches('.sp-head:not(.unfolded)') || expandedSpoilers.has(head)) { continue; }
expandedSpoilers.add(head);
spoilerClicks++;
try { head.click(); } catch (e) {}
if (spoilerClicks >= CONFIG.MAX_SPOILERS_CLICK) { spoilerQueue.clear(); break; }
}
if (spoilerQueue.size) { spoilerScheduled = true; requestAnimationFrame(stepSpoilers); }
}
function expandSpoilersIn(root) {
if (!CONFIG.EXPAND_SPOILERS || !root || spoilerClicks >= CONFIG.MAX_SPOILERS_CLICK) { return; }
const spoilers = [];
if (root.matches('.sp-head:not(.unfolded)')) { spoilers.push(root); }
spoilers.push.apply(spoilers, root.querySelectorAll('.sp-head:not(.unfolded)'));
for (const head of spoilers) {
if (spoilerClicks + spoilerQueue.size >= CONFIG.MAX_SPOILERS_CLICK) { break; }
if (!expandedSpoilers.has(head)) { spoilerQueue.add(head); }
}
if (spoilerQueue.size && !spoilerScheduled) {
spoilerScheduled = true;
requestAnimationFrame(stepSpoilers);
}
}
// ========== Scan groupé et mutations dynamiques ==========
function enqueueScan(node) {
if (!node || node.nodeType !== 1) { return; }
scanQueue.add(node);
if (scanTimer !== null) { return; }
scanTimer = setTimeout(function() {
scanTimer = null;
const nodes = Array.from(scanQueue);
scanQueue.clear();
for (const root of nodes) { scanRoot(root); }
pruneJobs();
}, CONFIG.SCAN_DEBOUNCE_MS);
}
const observer = new MutationObserver(function(mutations) {
let removed = false;
for (const mutation of mutations) {
if (mutation.type === 'childList') {
if (mutation.removedNodes.length) { removed = true; }
for (const node of mutation.addedNodes) {
if (node.nodeType !== 1) { continue; }
enqueueScan(node);
expandSpoilersIn(node);
}
} else {
const target = mutation.target;
if (target.tagName === 'IMG' || target.tagName === 'SOURCE' || target.tagName === 'A') {
const anchor = target.closest(ANCHORS);
if (anchor) { enqueueScan(anchor); }
}
}
}
if (removed) { pruneJobs(); }
});
function init() {
if (!document.body) { return; }
expandSpoilersIn(document.body);
enqueueScan(document.body);
observer.observe(document.body, {
childList: true, subtree: true, attributes: true,
attributeFilter: ['src', 'srcset', 'data-src', 'data-original', 'href', 'media', 'type']
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else { init(); }
})();