☰

Pornolab Preview Light

affiche une preview sur la page de recherche pour les liens

スクリプトをインストールするには、Tampermonkey, Greasemonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、 Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Violentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、Tampermonkey や Userscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら、通報はこちらへお寄せください
// ==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();
}

})();