☰

pornolabFullSizeImages

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

})();