☰

pornolabFullSizeImages

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

Terá de instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

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

Terá de instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Terá de instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Terá de instalar uma extensão como Tampermonkey para instalar este script.

Terá de instalar uma extensão de gestão de scripts de utilizador para instalar este script.

(Já tenho um gestor de scripts de utilizador, deixe-me instalá-lo!)

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão como Stylus para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

Terá de instalar uma extensão de gestão de estilos de utilizador para instalar este estilo.

(Já tenho um gestor de estilos de utilizador, deixe-me instalá-lo!)

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

})();