☰

pornolabFullSizeImages

Auto-expand spoilers + remplace les miniatures Fastpic par les images full-size

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         pornolabFullSizeImages
// @namespace    https://greasyfork.org/en/users/108513
// @version      3.4.1
// @description  Auto-expand spoilers + remplace les miniatures Fastpic par les images full-size
// @author       seb du 17
// @match        https://pornolab.net/*
// @match        http://pornolab.net/*
// @match        https://*.pornolab.net/*
// @match        http://*.pornolab.net/*
// @run-at       document-idle
// @icon         https://static.pornolab.net/favicon.ico
// @grant        GM.xmlHttpRequest
// @connect      fastpic.org
// @noframes
// @license      MIT
// ==/UserScript==

/* jshint esversion: 11, browser: true, curly: true */
/* global GM, DOMParser */

(function () {
  'use strict';

  const CONFIG = {
    SCAN_DEBOUNCE_MS:    100,
    FETCH_TIMEOUT_MS:   5000,
    RETRY_DELAY_MS:     3000,
    MAX_RETRIES:           3,
    MAX_CONCURRENT_REQUESTS: 6,
    CACHE_SIZE:          150,

    EXPAND_SPOILERS:      true,
    MAX_SPOILERS_CLICK:   1200,
    SPOILER_CLICK_CHUNK:    25
  };

  const REGEX = {
    MD5_PARAM:     /[?&]md5=/i,
    BIG_PATH:      /fastpic\.org\/big\//i,
    FULLVIEW_PATH: /fastpic\.org\/fullview\/.+\.html/i,
    THUMB_PATH:    /\/thumb\//g,
    BIG_URL_FALLBACK: /https?:\/\/(?:i\d+\.)?fastpic\.org\/big\/[^"'<>\s]+/i
  };

  // Les éléments supprimés du DOM restent récupérables par le GC.
  const processed = new WeakSet();
  const pending = new WeakSet();

  const urlCache = new Map();       // fullviewUrl -> bigUrl
  const inFlight = new Map();       // fullviewUrl -> Promise<string>
  const waitingImages = new Map();  // fullviewUrl -> Set<HTMLImageElement>
  const retryCounts = new Map();    // fullviewUrl -> nombre de retries
  const retryTimers = new Map();    // fullviewUrl -> timer

  const scanQueue = new Set();
  const requestQueue = [];
  let activeRequests = 0;
  let scanTimer = null;

  // ========== Cache LRU ==========
  function cacheGet(key) {
    if (!urlCache.has(key)) { return undefined; }
    const value = urlCache.get(key);
    urlCache.delete(key);
    urlCache.set(key, value);
    return value;
  }

  function cacheSet(key, value) {
    if (urlCache.has(key)) { urlCache.delete(key); }
    urlCache.set(key, value);
    if (urlCache.size > CONFIG.CACHE_SIZE) {
      urlCache.delete(urlCache.keys().next().value);
    }
  }

  // ========== Utilitaires ==========
  function safeURL(str) {
    try { return new URL(str, location.href); }
    catch (e) { return null; }
  }

  function ensureParam(urlStr, key, value) {
    try {
      const url = new URL(urlStr, location.href);
      if (!url.searchParams.has(key)) { url.searchParams.set(key, value); }
      return url.toString();
    } catch (e) {
      const sep = urlStr.indexOf('?') >= 0 ? '&' : '?';
      return urlStr + sep + encodeURIComponent(key) + '=' + encodeURIComponent(value);
    }
  }

  function setOptimizedImageFlags(img) {
    try { img.loading = 'lazy'; } catch (e) {}
    try { img.decoding = 'async'; } catch (e) {}
    try { img.fetchPriority = 'low'; } catch (e) {}
  }

  // ========== Requête FireMonkey 3.x ==========
  async function doXFetchText(url) {
    if (typeof GM === 'undefined' || !GM || typeof GM.xmlHttpRequest !== 'function') {
      return '';
    }

    try {
      const response = await GM.xmlHttpRequest({
        method: 'GET',
        url: url,
        timeout: CONFIG.FETCH_TIMEOUT_MS,
        responseType: 'text'
      });

      if (!response) { return ''; }
      if (response.status && (response.status < 200 || response.status >= 400)) { return ''; }
      return response.responseText || (typeof response.response === 'string' ? response.response : '') || '';
    } catch (e) {
      return '';
    }
  }

  // File globale : évite de lancer des dizaines de GM.xmlHttpRequest simultanés.
  function processRequestQueue() {
    while (requestQueue.length && activeRequests < CONFIG.MAX_CONCURRENT_REQUESTS) {
      const item = requestQueue.shift();
      activeRequests++;

      doXFetchText(item.url)
        .then(item.resolve, function() { item.resolve(''); })
        .finally(function() {
          activeRequests--;
          processRequestQueue();
        });
    }
  }

  function xfetchText(url) {
    return new Promise(function(resolve) {
      requestQueue.push({ url: url, resolve: resolve });
      processRequestQueue();
    });
  }

  // ========== Extraction robuste du lien /big/ ==========
  function extractBigUrl(html, sourceUrl) {
    if (!html) { return ''; }

    try {
      const doc = new DOMParser().parseFromString(html, 'text/html');
      const candidates = doc.querySelectorAll(
        'a[href*="fastpic.org/big/"], img[src*="fastpic.org/big/"], source[srcset*="fastpic.org/big/"]'
      );

      for (let i = 0; i < candidates.length; i++) {
        const el = candidates[i];
        let raw = el.getAttribute('href') || el.getAttribute('src') || el.getAttribute('srcset') || '';
        if (!raw) { continue; }
        if (el.tagName === 'SOURCE' && raw.indexOf(',') >= 0) { raw = raw.split(',')[0].trim().split(/\s+/)[0]; }

        try {
          const candidate = new URL(raw, sourceUrl).href;
          if (REGEX.BIG_PATH.test(candidate) && REGEX.MD5_PARAM.test(candidate)) { return candidate; }
        } catch (e) {}
      }
    } catch (e) {}

    // Secours pour les URLs injectées dans un script plutôt que dans le DOM HTML.
    const normalized = html.replace(/\\\//g, '/').replace(/&amp;/g, '&');
    const match = normalized.match(REGEX.BIG_URL_FALLBACK);
    if (!match) { return ''; }

    const candidate = match[0];
    return REGEX.MD5_PARAM.test(candidate) ? candidate : '';
  }

  // ========== Fetch fullview -> big URL ==========
  function fetchBigUrlFromFullview(fullviewUrl) {
    const cached = cacheGet(fullviewUrl);
    if (cached !== undefined) { return Promise.resolve(cached); }

    if (inFlight.has(fullviewUrl)) { return inFlight.get(fullviewUrl); }

    const promise = xfetchText(fullviewUrl)
      .then(function(html) {
        const result = extractBigUrl(html, fullviewUrl);
        if (result) { cacheSet(fullviewUrl, result); }
        return result;
      })
      .catch(function() { return ''; })
      .finally(function() { inFlight.delete(fullviewUrl); });

    inFlight.set(fullviewUrl, promise);
    return promise;
  }

  // ========== Images en attente + retry partagé ==========
  function addWaitingImage(fullviewUrl, img) {
    let images = waitingImages.get(fullviewUrl);
    if (!images) {
      images = new Set();
      waitingImages.set(fullviewUrl, images);
    }
    images.add(img);
    pending.add(img);
  }

  function resolveWaitingImages(fullviewUrl, bigUrl) {
    const images = waitingImages.get(fullviewUrl);
    waitingImages.delete(fullviewUrl);
    retryCounts.delete(fullviewUrl);

    const timer = retryTimers.get(fullviewUrl);
    if (timer !== undefined) { clearTimeout(timer); }
    retryTimers.delete(fullviewUrl);

    if (!images) { return; }
    const finalUrl = ensureParam(bigUrl, 'noht', '1');

    images.forEach(function(img) {
      pending.delete(img);
      if (!img || processed.has(img)) { return; }
      processed.add(img);
      setOptimizedImageFlags(img);
      img.src = finalUrl;
    });
  }

  function releaseWaitingImages(fullviewUrl) {
    const images = waitingImages.get(fullviewUrl);
    waitingImages.delete(fullviewUrl);
    retryCounts.delete(fullviewUrl);
    retryTimers.delete(fullviewUrl);
    if (images) { images.forEach(function(img) { pending.delete(img); }); }
  }

  function scheduleRetry(fullviewUrl) {
    if (retryTimers.has(fullviewUrl)) { return; }

    const attempt = retryCounts.get(fullviewUrl) || 0;
    if (attempt >= CONFIG.MAX_RETRIES) {
      releaseWaitingImages(fullviewUrl);
      return;
    }
    retryCounts.set(fullviewUrl, attempt + 1);

    const delay = CONFIG.RETRY_DELAY_MS * Math.pow(2, attempt); // 3s, 6s, 12s
    const timer = setTimeout(function() {
      retryTimers.delete(fullviewUrl);

      if (!waitingImages.has(fullviewUrl)) {
        retryCounts.delete(fullviewUrl);
        return;
      }

      fetchBigUrlFromFullview(fullviewUrl).then(function(bigUrl) {
        if (bigUrl) {
          resolveWaitingImages(fullviewUrl, bigUrl);
        } else {
          scheduleRetry(fullviewUrl);
        }
      });
    }, delay);

    retryTimers.set(fullviewUrl, timer);
  }

  function requestFullview(fullviewUrl, img) {
    addWaitingImage(fullviewUrl, img);

    fetchBigUrlFromFullview(fullviewUrl).then(function(bigUrl) {
      if (bigUrl) {
        resolveWaitingImages(fullviewUrl, bigUrl);
      } else {
        scheduleRetry(fullviewUrl);
      }
    });
  }

  // ========== Traitement d'une ancre ==========
  function processAnchor(a) {
    if (!a || a.nodeType !== 1) { return; }

    const img = a.querySelector('img.postImg, img');
    if (!img || processed.has(img) || pending.has(img)) { return; }

    const raw =
      img.currentSrc ||
      img.getAttribute('src') ||
      img.getAttribute('data-src') ||
      img.getAttribute('data-original') ||
      '';

    const rawUrl = raw ? safeURL(raw) : null;
    const hrefUrl = a.href ? safeURL(a.href) : null;
    const rawHost = rawUrl ? rawUrl.hostname : '';
    const hrefHost = hrefUrl ? hrefUrl.hostname : '';

    if (!rawHost.endsWith('fastpic.org') && !hrefHost.endsWith('fastpic.org')) { return; }

    setOptimizedImageFlags(img);

    // Cas 1 : thumb -> big si l'URL contient déjà la signature md5.
    if (raw && raw.indexOf('/thumb/') >= 0) {
      const candidate = raw.replace(REGEX.THUMB_PATH, '/big/');
      if (REGEX.MD5_PARAM.test(candidate)) {
        processed.add(img);
        img.src = ensureParam(candidate, 'noht', '1');
        return;
      }
    }

    // Cas 2 : lien href direct /big/.
    if (a.href && REGEX.BIG_PATH.test(a.href) && REGEX.MD5_PARAM.test(a.href)) {
      processed.add(img);
      img.src = ensureParam(a.href, 'noht', '1');
      return;
    }

    // Cas 3 : fullview HTML -> une seule requête partagée par URL.
    if (a.href && REGEX.FULLVIEW_PATH.test(a.href)) {
      requestFullview(a.href, img);
    }
  }

  // ========== Scan d'un sous-arbre ==========
  function scanRoot(root) {
    if (!root || root.nodeType !== 1) { return; }

    if (root.matches && root.matches('a.postLink, a[href*="fastpic.org/"]')) {
      processAnchor(root);
    }

    if (root.querySelectorAll) {
      const anchors = root.querySelectorAll('a.postLink, a[href*="fastpic.org/"]');
      for (let i = 0; i < anchors.length; i++) { processAnchor(anchors[i]); }
    }
  }

  // ========== Expand spoilers ==========
  function expandSpoilersIn(root) {
    if (!CONFIG.EXPAND_SPOILERS || !root) { return; }

    let spoilers;
    if (root === document.body) {
      spoilers = Array.from(document.querySelectorAll('.sp-head:not(.unfolded)'));
    } else {
      spoilers = [];
      if (root.matches && root.matches('.sp-head:not(.unfolded)')) { spoilers.push(root); }
      if (root.querySelectorAll) {
        spoilers.push.apply(spoilers, root.querySelectorAll('.sp-head:not(.unfolded)'));
      }
    }

    if (!spoilers.length) { return; }
    if (root === document.body && spoilers.length > CONFIG.MAX_SPOILERS_CLICK) { return; }

    let index = 0;
    function step() {
      const end = Math.min(index + CONFIG.SPOILER_CLICK_CHUNK, spoilers.length);
      for (; index < end; index++) {
        try { spoilers[index].click(); } catch (e) {}
      }
      if (index < spoilers.length) { requestAnimationFrame(step); }
    }
    requestAnimationFrame(step);
  }

  // ========== Queue de scan debounced ==========
  function enqueueScan(node) {
    if (!node || node.nodeType !== 1) { return; }
    scanQueue.add(node);
    if (scanTimer !== null) { return; }

    scanTimer = setTimeout(function() {
      scanTimer = null;
      const nodes = Array.from(scanQueue);
      scanQueue.clear();
      for (let i = 0; i < nodes.length; i++) { scanRoot(nodes[i]); }
    }, CONFIG.SCAN_DEBOUNCE_MS);
  }

  // ========== MutationObserver ==========
  const observer = new MutationObserver(function(mutationList) {
    for (let i = 0; i < mutationList.length; i++) {
      const mutation = mutationList[i];

      if (mutation.type === 'childList') {
        for (let j = 0; j < mutation.addedNodes.length; j++) {
          const node = mutation.addedNodes[j];
          if (!node || node.nodeType !== 1) { continue; }
          enqueueScan(node);
          expandSpoilersIn(node);
        }
        continue;
      }

      if (mutation.type === 'attributes' && mutation.target && mutation.target.nodeType === 1) {
        const target = mutation.target;

        if (target.tagName === 'IMG') {
          const anchor = target.closest ? target.closest('a.postLink, a[href*="fastpic.org/"]') : null;
          if (anchor) { enqueueScan(anchor); }
        } else if (target.tagName === 'A' && target.matches('a.postLink, a[href*="fastpic.org/"]')) {
          enqueueScan(target);
        }
      }
    }
  });

  // ========== Init / cleanup ==========
  function init() {
    if (!document.body) { return; }

    expandSpoilersIn(document.body);
    enqueueScan(document.body);

    observer.observe(document.body, {
      childList: true,
      subtree: true,
      attributes: true,
      attributeFilter: ['src', 'srcset', 'data-src', 'data-original', 'href']
    });
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', init, { once: true });
  } else {
    init();
  }

})();