Sleazy Fork is available in English.
affiche une preview sur la page de recherche pour les liens
// ==UserScript==
// @name Pornolab Preview Light
// @version 3.7.1
// @description affiche une preview sur la page de recherche pour les liens
// @author seb-du17
// @license MIT
// @match *://pornolab.net/forum/tracker*
// @match *://pornolab.net/forum/viewforum*
// @match *://pornolab.net/forum/search*
// @run-at document-idle
// @grant none
// @namespace https://greasyfork.org/users/108513
// ==/UserScript==
/* global DOMParser, Image */
(function() {
'use strict';
// ========== Configuration ==========
const CONFIG = {
MAX_CONCURRENT: 6,
TIMEOUT: 10000,
RETRY_DELAY: 3000,
MAX_RETRIES: 3,
CACHE_SIZE: 200,
DEBOUNCE: 80,
IMG_WIDTH: 180,
IMG_HEIGHT: 210
};
// ========== Cache LRU ==========
function createLRUCache(maxSize) {
const cache = new Map();
return {
get: function(key) {
if (!cache.has(key)) { return null; }
const value = cache.get(key);
cache.delete(key);
cache.set(key, value);
return value;
},
set: function(key, value) {
if (cache.has(key)) {
cache.delete(key);
} else if (cache.size >= maxSize) {
cache.delete(cache.keys().next().value);
}
cache.set(key, value);
}
};
}
const cache = createLRUCache(CONFIG.CACHE_SIZE);
const inFlight = new Map(); // url -> Promise<string[]>
const requestQueue = [];
const retryCounts = new Map(); // url -> nombre de retries déjà programmés
const retryTimers = new Map(); // url -> timer
const retryLinks = new Map(); // url -> Set<HTMLAnchorElement>
let activeRequests = 0;
// ========== CSS ==========
const style = document.createElement('style');
style.textContent = `
.pornolab-preview-wrapper {
display: flex !important;
align-items: flex-end !important;
gap: 10px !important;
width: 100% !important;
}
.pornolab-preview-wrapper-link {
flex: 1 1 auto !important;
min-width: 0 !important;
}
.pornolab-preview-img-container {
width: ${CONFIG.IMG_WIDTH}px !important;
height: ${CONFIG.IMG_HEIGHT}px !important;
flex-shrink: 0 !important;
overflow: hidden !important;
border: 1px solid #444 !important;
border-radius: 2px !important;
content-visibility: auto !important;
}
.pornolab-preview-img-container img {
width: 100% !important;
height: 100% !important;
object-fit: cover !important;
display: block !important;
}
`;
(document.head || document.documentElement).appendChild(style);
// ========== Fetch avec timeout ==========
function fetchWithTimeout(url, timeout) {
return fetch(url, {
signal: AbortSignal.timeout(timeout),
priority: 'low',
credentials: 'same-origin'
});
}
// ========== Extraction URL preview ==========
function extractPreviewUrl(doc, sourceUrl) {
const firstImg = doc.querySelector('.postImg');
if (!firstImg) { return ''; }
const raw = firstImg.title || firstImg.getAttribute('data-src') || firstImg.getAttribute('src') || '';
if (!raw) { return ''; }
try { return new URL(raw, sourceUrl).href; }
catch (e) { return raw; }
}
// ========== Queue de requêtes ==========
async function processQueueItem(item) {
try {
const response = await fetchWithTimeout(item.url, CONFIG.TIMEOUT);
if (!response.ok) {
throw new Error('HTTP ' + response.status);
}
const html = await response.text();
const doc = new DOMParser().parseFromString(html, 'text/html');
const previewUrl = extractPreviewUrl(doc, item.url);
const urls = previewUrl ? [previewUrl] : [];
// Une page chargée correctement sans preview est un résultat valide :
// on la met en cache pour éviter de la télécharger en boucle.
cache.set(item.url, urls);
item.resolve(urls);
} catch (error) {
// Erreur réseau/timeout/HTTP : surtout ne pas mettre [] en cache.
item.reject(error);
} finally {
activeRequests--;
processQueue();
}
}
function processQueue() {
while (requestQueue.length && activeRequests < CONFIG.MAX_CONCURRENT) {
const item = requestQueue.shift();
activeRequests++;
processQueueItem(item);
}
}
function queueFetch(url) {
return new Promise(function(resolve, reject) {
requestQueue.push({ resolve: resolve, reject: reject, url: url });
processQueue();
});
}
function fetchPreviewUrls(url) {
const cached = cache.get(url);
if (cached !== null) { return Promise.resolve(cached); }
if (inFlight.has(url)) { return inFlight.get(url); }
const promise = queueFetch(url).finally(function() {
inFlight.delete(url);
});
inFlight.set(url, promise);
return promise;
}
// ========== Insert preview ==========
function installPreview(urls, link) {
if (!urls.length || !link || !link.parentNode || link.dataset.previewRendered) { return; }
const parent = link.parentNode;
const wrapper = document.createElement('div');
wrapper.className = 'pornolab-preview-wrapper';
const linkWrapper = document.createElement('div');
linkWrapper.className = 'pornolab-preview-wrapper-link';
const imgContainer = document.createElement('div');
imgContainer.className = 'pornolab-preview-img-container';
const img = new Image();
// Toutes les previews doivent être réellement chargées, même hors viewport.
img.loading = 'eager';
img.decoding = 'async';
try { img.fetchPriority = 'low'; } catch (e) {}
img.onerror = function() { imgContainer.remove(); };
img.src = urls[0];
imgContainer.appendChild(img);
// Remplacer d'abord le lien puis déplacer le vrai nœud dans le wrapper.
// Cela conserve ses éventuels event listeners, contrairement à cloneNode(true).
parent.replaceChild(wrapper, link);
linkWrapper.appendChild(link);
wrapper.appendChild(linkWrapper);
wrapper.appendChild(imgContainer);
link.dataset.previewRendered = '1';
}
// ========== Retry silencieux avec backoff ==========
function cleanupRetry(url) {
const timer = retryTimers.get(url);
if (timer !== undefined) { clearTimeout(timer); }
retryTimers.delete(url);
retryCounts.delete(url);
retryLinks.delete(url);
}
function scheduleRetry(url, link) {
if (!link) { return; }
let links = retryLinks.get(url);
if (!links) {
links = new Set();
retryLinks.set(url, links);
}
links.add(link);
// Une seule minuterie par URL, même si plusieurs liens pointent vers le même topic.
if (retryTimers.has(url)) { return; }
const attempt = retryCounts.get(url) || 0;
if (attempt >= CONFIG.MAX_RETRIES) {
cleanupRetry(url);
return;
}
retryCounts.set(url, attempt + 1);
const delay = CONFIG.RETRY_DELAY * Math.pow(2, attempt); // 3s, 6s, 12s
const timer = setTimeout(function() {
retryTimers.delete(url);
fetchPreviewUrls(url).then(function(urls) {
const waitingLinks = retryLinks.get(url) || new Set();
cleanupRetry(url);
if (!urls.length) { return; }
waitingLinks.forEach(function(waitingLink) {
if (waitingLink.isConnected) { installPreview(urls, waitingLink); }
});
}).catch(function() {
const waitingLinks = retryLinks.get(url);
const firstConnected = waitingLinks && Array.from(waitingLinks).find(function(a) { return a.isConnected; });
if (firstConnected) {
scheduleRetry(url, firstConnected);
} else {
cleanupRetry(url);
}
});
}, delay);
retryTimers.set(url, timer);
}
function insertPreview(link) {
if (!link || link.dataset.previewDone) { return; }
const href = link.getAttribute('href');
if (!href) { return; }
let url;
try { url = new URL(href, document.baseURI).href; }
catch (e) { return; }
// Le script ne doit récupérer que des pages Pornolab.
try {
const parsed = new URL(url);
if (parsed.hostname !== 'pornolab.net' && !parsed.hostname.endsWith('.pornolab.net')) { return; }
} catch (e) { return; }
link.dataset.previewDone = '1';
fetchPreviewUrls(url).then(function(urls) {
if (urls.length) { installPreview(urls, link); }
}).catch(function() {
scheduleRetry(url, link);
});
}
// ========== Préchargement de tous les topics ==========
// Pas d'IntersectionObserver ici : chaque lien présent dans la page est mis en
// file immédiatement. MAX_CONCURRENT limite toujours le nombre de requêtes
// simultanées, donc le préchargement reste contrôlé sans dépendre du scroll.
const selector = '.tLink:not([data-preview-done]), .tt-text:not([data-preview-done])';
function processRoot(root) {
if (!root || root.nodeType !== 1) { return; }
if (root.matches && root.matches(selector)) { insertPreview(root); }
if (!root.querySelectorAll) { return; }
const links = root.querySelectorAll(selector);
for (let i = 0; i < links.length; i++) { insertPreview(links[i]); }
}
// ========== MutationObserver : uniquement les sous-arbres ajoutés ==========
const scanRoots = new Set();
let debounceTimer = null;
function enqueueRoot(root) {
if (!root || root.nodeType !== 1) { return; }
scanRoots.add(root);
if (debounceTimer !== null) { return; }
debounceTimer = setTimeout(function() {
debounceTimer = null;
const roots = Array.from(scanRoots);
scanRoots.clear();
for (let i = 0; i < roots.length; i++) { processRoot(roots[i]); }
}, CONFIG.DEBOUNCE);
}
const mutationObserver = new MutationObserver(function(mutations) {
for (let i = 0; i < mutations.length; i++) {
const added = mutations[i].addedNodes;
for (let j = 0; j < added.length; j++) {
if (added[j].nodeType === 1) { enqueueRoot(added[j]); }
}
}
});
function init() {
if (!document.body) { return; }
processRoot(document.body);
mutationObserver.observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init, { once: true });
} else {
init();
}
})();