☰

Pornolab Preview Light

affiche une preview sur la page de recherche pour les liens

您需要先安裝使用者腳本管理器擴展,如 Tampermonkey、Greasemonkey 或 Violentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 Tampermonkey或 Violentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 Tampermonkey或 Userscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

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

})();