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.1
// @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 REGEX = {
MD5_PARAM: /[?&]md5=/i,
BIG_PATH: /fastpic\.org\/big\//i,
FULLVIEW_PATH: /fastpic\.org\/fullview\/.+\.html/i,
THUMB_PATH: /\/thumb\//g,
BIG_URL_FALLBACK: /https?:\/\/(?:i\d+\.)?fastpic\.org\/big\/[^"'<>\s]+/i
};
// Les éléments supprimés du DOM restent récupérables par le GC.
const processed = new WeakSet();
const pending = new WeakSet();
const urlCache = new Map(); // fullviewUrl -> bigUrl
const inFlight = new Map(); // fullviewUrl -> Promise<string>
const waitingImages = new Map(); // fullviewUrl -> Set<HTMLImageElement>
const retryCounts = new Map(); // fullviewUrl -> nombre de retries
const retryTimers = new Map(); // fullviewUrl -> timer
const scanQueue = new Set();
const requestQueue = [];
let activeRequests = 0;
let scanTimer = null;
// ========== Cache LRU ==========
function cacheGet(key) {
if (!urlCache.has(key)) { return undefined; }
const value = urlCache.get(key);
urlCache.delete(key);
urlCache.set(key, value);
return value;
}
function cacheSet(key, value) {
if (urlCache.has(key)) { urlCache.delete(key); }
urlCache.set(key, value);
if (urlCache.size > CONFIG.CACHE_SIZE) {
urlCache.delete(urlCache.keys().next().value);
}
}
// ========== Utilitaires ==========
function safeURL(str) {
try { return new URL(str, location.href); }
catch (e) { return null; }
}
function ensureParam(urlStr, key, value) {
try {
const url = new URL(urlStr, location.href);
if (!url.searchParams.has(key)) { url.searchParams.set(key, value); }
return url.toString();
} catch (e) {
const sep = urlStr.indexOf('?') >= 0 ? '&' : '?';
return urlStr + sep + encodeURIComponent(key) + '=' + encodeURIComponent(value);
}
}
function setOptimizedImageFlags(img) {
try { img.loading = 'lazy'; } catch (e) {}
try { img.decoding = 'async'; } catch (e) {}
try { img.fetchPriority = 'low'; } catch (e) {}
}
// ========== Requête FireMonkey 3.x ==========
async function doXFetchText(url) {
if (typeof GM === 'undefined' || !GM || typeof GM.xmlHttpRequest !== 'function') {
return '';
}
try {
const response = await GM.xmlHttpRequest({
method: 'GET',
url: url,
timeout: CONFIG.FETCH_TIMEOUT_MS,
responseType: 'text'
});
if (!response) { return ''; }
if (response.status && (response.status < 200 || response.status >= 400)) { return ''; }
return response.responseText || (typeof response.response === 'string' ? response.response : '') || '';
} catch (e) {
return '';
}
}
// File globale : évite de lancer des dizaines de GM.xmlHttpRequest simultanés.
function processRequestQueue() {
while (requestQueue.length && activeRequests < CONFIG.MAX_CONCURRENT_REQUESTS) {
const item = requestQueue.shift();
activeRequests++;
doXFetchText(item.url)
.then(item.resolve, function() { item.resolve(''); })
.finally(function() {
activeRequests--;
processRequestQueue();
});
}
}
function xfetchText(url) {
return new Promise(function(resolve) {
requestQueue.push({ url: url, resolve: resolve });
processRequestQueue();
});
}
// ========== Extraction robuste 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*="fastpic.org/big/"], img[src*="fastpic.org/big/"], source[srcset*="fastpic.org/big/"]'
);
for (let i = 0; i < candidates.length; i++) {
const el = candidates[i];
let raw = el.getAttribute('href') || el.getAttribute('src') || el.getAttribute('srcset') || '';
if (!raw) { continue; }
if (el.tagName === 'SOURCE' && raw.indexOf(',') >= 0) { raw = raw.split(',')[0].trim().split(/\s+/)[0]; }
try {
const candidate = new URL(raw, sourceUrl).href;
if (REGEX.BIG_PATH.test(candidate) && REGEX.MD5_PARAM.test(candidate)) { return candidate; }
} catch (e) {}
}
} catch (e) {}
// Secours pour les URLs injectées dans un script plutôt que dans le DOM HTML.
const normalized = html.replace(/\\\//g, '/').replace(/&/g, '&');
const match = normalized.match(REGEX.BIG_URL_FALLBACK);
if (!match) { return ''; }
const candidate = match[0];
return REGEX.MD5_PARAM.test(candidate) ? candidate : '';
}
// ========== Fetch fullview -> big URL ==========
function fetchBigUrlFromFullview(fullviewUrl) {
const cached = cacheGet(fullviewUrl);
if (cached !== undefined) { return Promise.resolve(cached); }
if (inFlight.has(fullviewUrl)) { return inFlight.get(fullviewUrl); }
const promise = xfetchText(fullviewUrl)
.then(function(html) {
const result = extractBigUrl(html, fullviewUrl);
if (result) { cacheSet(fullviewUrl, result); }
return result;
})
.catch(function() { return ''; })
.finally(function() { inFlight.delete(fullviewUrl); });
inFlight.set(fullviewUrl, promise);
return promise;
}
// ========== Images en attente + retry partagé ==========
function addWaitingImage(fullviewUrl, img) {
let images = waitingImages.get(fullviewUrl);
if (!images) {
images = new Set();
waitingImages.set(fullviewUrl, images);
}
images.add(img);
pending.add(img);
}
function resolveWaitingImages(fullviewUrl, bigUrl) {
const images = waitingImages.get(fullviewUrl);
waitingImages.delete(fullviewUrl);
retryCounts.delete(fullviewUrl);
const timer = retryTimers.get(fullviewUrl);
if (timer !== undefined) { clearTimeout(timer); }
retryTimers.delete(fullviewUrl);
if (!images) { return; }
const finalUrl = ensureParam(bigUrl, 'noht', '1');
images.forEach(function(img) {
pending.delete(img);
if (!img || processed.has(img)) { return; }
processed.add(img);
setOptimizedImageFlags(img);
img.src = finalUrl;
});
}
function releaseWaitingImages(fullviewUrl) {
const images = waitingImages.get(fullviewUrl);
waitingImages.delete(fullviewUrl);
retryCounts.delete(fullviewUrl);
retryTimers.delete(fullviewUrl);
if (images) { images.forEach(function(img) { pending.delete(img); }); }
}
function scheduleRetry(fullviewUrl) {
if (retryTimers.has(fullviewUrl)) { return; }
const attempt = retryCounts.get(fullviewUrl) || 0;
if (attempt >= CONFIG.MAX_RETRIES) {
releaseWaitingImages(fullviewUrl);
return;
}
retryCounts.set(fullviewUrl, attempt + 1);
const delay = CONFIG.RETRY_DELAY_MS * Math.pow(2, attempt); // 3s, 6s, 12s
const timer = setTimeout(function() {
retryTimers.delete(fullviewUrl);
if (!waitingImages.has(fullviewUrl)) {
retryCounts.delete(fullviewUrl);
return;
}
fetchBigUrlFromFullview(fullviewUrl).then(function(bigUrl) {
if (bigUrl) {
resolveWaitingImages(fullviewUrl, bigUrl);
} else {
scheduleRetry(fullviewUrl);
}
});
}, delay);
retryTimers.set(fullviewUrl, timer);
}
function requestFullview(fullviewUrl, img) {
addWaitingImage(fullviewUrl, img);
fetchBigUrlFromFullview(fullviewUrl).then(function(bigUrl) {
if (bigUrl) {
resolveWaitingImages(fullviewUrl, bigUrl);
} else {
scheduleRetry(fullviewUrl);
}
});
}
// ========== Traitement d'une ancre ==========
function processAnchor(a) {
if (!a || a.nodeType !== 1) { return; }
const img = a.querySelector('img.postImg, img');
if (!img || processed.has(img) || pending.has(img)) { return; }
const raw =
img.currentSrc ||
img.getAttribute('src') ||
img.getAttribute('data-src') ||
img.getAttribute('data-original') ||
'';
const rawUrl = raw ? safeURL(raw) : null;
const hrefUrl = a.href ? safeURL(a.href) : null;
const rawHost = rawUrl ? rawUrl.hostname : '';
const hrefHost = hrefUrl ? hrefUrl.hostname : '';
if (!rawHost.endsWith('fastpic.org') && !hrefHost.endsWith('fastpic.org')) { return; }
setOptimizedImageFlags(img);
// Cas 1 : thumb -> big si l'URL contient déjà la signature md5.
if (raw && raw.indexOf('/thumb/') >= 0) {
const candidate = raw.replace(REGEX.THUMB_PATH, '/big/');
if (REGEX.MD5_PARAM.test(candidate)) {
processed.add(img);
img.src = ensureParam(candidate, 'noht', '1');
return;
}
}
// Cas 2 : lien href direct /big/.
if (a.href && REGEX.BIG_PATH.test(a.href) && REGEX.MD5_PARAM.test(a.href)) {
processed.add(img);
img.src = ensureParam(a.href, 'noht', '1');
return;
}
// Cas 3 : fullview HTML -> une seule requête partagée par URL.
if (a.href && REGEX.FULLVIEW_PATH.test(a.href)) {
requestFullview(a.href, img);
}
}
// ========== Scan d'un sous-arbre ==========
function scanRoot(root) {
if (!root || root.nodeType !== 1) { return; }
if (root.matches && root.matches('a.postLink, a[href*="fastpic.org/"]')) {
processAnchor(root);
}
if (root.querySelectorAll) {
const anchors = root.querySelectorAll('a.postLink, a[href*="fastpic.org/"]');
for (let i = 0; i < anchors.length; i++) { processAnchor(anchors[i]); }
}
}
// ========== Expand spoilers ==========
function expandSpoilersIn(root) {
if (!CONFIG.EXPAND_SPOILERS || !root) { return; }
let spoilers;
if (root === document.body) {
spoilers = Array.from(document.querySelectorAll('.sp-head:not(.unfolded)'));
} else {
spoilers = [];
if (root.matches && root.matches('.sp-head:not(.unfolded)')) { spoilers.push(root); }
if (root.querySelectorAll) {
spoilers.push.apply(spoilers, root.querySelectorAll('.sp-head:not(.unfolded)'));
}
}
if (!spoilers.length) { return; }
if (root === document.body && spoilers.length > CONFIG.MAX_SPOILERS_CLICK) { return; }
let index = 0;
function step() {
const end = Math.min(index + CONFIG.SPOILER_CLICK_CHUNK, spoilers.length);
for (; index < end; index++) {
try { spoilers[index].click(); } catch (e) {}
}
if (index < spoilers.length) { requestAnimationFrame(step); }
}
requestAnimationFrame(step);
}
// ========== Queue de scan debounced ==========
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 (let i = 0; i < nodes.length; i++) { scanRoot(nodes[i]); }
}, CONFIG.SCAN_DEBOUNCE_MS);
}
// ========== MutationObserver ==========
const observer = new MutationObserver(function(mutationList) {
for (let i = 0; i < mutationList.length; i++) {
const mutation = mutationList[i];
if (mutation.type === 'childList') {
for (let j = 0; j < mutation.addedNodes.length; j++) {
const node = mutation.addedNodes[j];
if (!node || node.nodeType !== 1) { continue; }
enqueueScan(node);
expandSpoilersIn(node);
}
continue;
}
if (mutation.type === 'attributes' && mutation.target && mutation.target.nodeType === 1) {
const target = mutation.target;
if (target.tagName === 'IMG') {
const anchor = target.closest ? target.closest('a.postLink, a[href*="fastpic.org/"]') : null;
if (anchor) { enqueueScan(anchor); }
} else if (target.tagName === 'A' && target.matches('a.postLink, a[href*="fastpic.org/"]')) {
enqueueScan(target);
}
}
}
});
// ========== Init / cleanup ==========
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']
});
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();