☰

Pornolab Preview Light

affiche une preview sur la page de recherche pour les liens

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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

})();