☰

pornolabFullSizeImages

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

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.

ستحتاج إلى تثبيت إضافة مثل Stylus لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتتمكن من تثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

ستحتاج إلى تثبيت إضافة لإدارة أنماط المستخدم لتثبيت هذا النمط.

(لدي بالفعل مثبت أنماط للمستخدم، دعني أقم بتثبيته!)

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

})();