☰

Pornolab Preview Light

affiche une preview sur la page de recherche pour les liens

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

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

})();