☰

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와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         pornolabFullSizeImages
// @namespace    https://greasyfork.org/en/users/108513
// @version      3.4.2
// @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 ANCHORS = 'a.postLink, a[href*="fastpic.org/"]';
  const IMAGE_ATTRS = ['src', 'srcset', 'data-src', 'data-original'];
  const imageStates = new WeakMap();
  const urlCache = new Map();       // fullviewUrl -> bigUrl (LRU)
  const jobs = new Map();           // fullviewUrl -> requête et retries partagés
  const requestQueue = [];
  const scanQueue = new Set();
  const spoilerQueue = new Set();
  const expandedSpoilers = new WeakSet();
  let activeRequests = 0;
  let scanTimer = null;
  let spoilerScheduled = false;
  let spoilerClicks = 0;

  // ========== URLs et signatures ==========
  function fastpicURL(raw, base) {
    if (!raw) { return null; }
    try {
      const url = new URL(raw, base || location.href);
      if (url.protocol !== 'https:' && url.protocol !== 'http:') { return null; }
      if (url.hostname !== 'fastpic.org' && !url.hostname.endsWith('.fastpic.org')) { return null; }
      if (url.username || url.password) { return null; }
      return url;
    } catch (e) { return null; }
  }

  function signedBigURL(raw, base) {
    const url = fastpicURL(raw, base);
    if (!url || !/^\/big\//i.test(url.pathname)) { return ''; }
    for (const entry of url.searchParams) {
      if (entry[0].toLowerCase() === 'md5' && entry[1]) { return url.href; }
    }
    return '';
  }

  function fullviewURL(raw) {
    const url = fastpicURL(raw);
    return url && /^\/fullview\/.+\.html$/i.test(url.pathname) ? url.href : '';
  }

  function thumbnailToBig(raw) {
    const url = fastpicURL(raw);
    if (!url || !/^\/thumb\//i.test(url.pathname)) { return ''; }
    url.pathname = url.pathname.replace(/^\/thumb\//i, '/big/');
    return signedBigURL(url.href);
  }

  function ensureNoht(raw) {
    const url = new URL(raw);
    if (url.searchParams.has('noht')) { return url.href; }
    // Ne pas réencoder les paramètres existants : ils portent la signature.
    const hashAt = url.href.indexOf('#');
    const base = hashAt < 0 ? url.href : url.href.slice(0, hashAt);
    const hash = hashAt < 0 ? '' : url.href.slice(hashAt);
    const sep = base.indexOf('?') < 0 ? '?' : /[?&]$/.test(base) ? '' : '&';
    return base + sep + 'noht=1' + hash;
  }

  function srcsetURLs(raw) {
    return (raw || '').split(',').map(function(candidate) {
      return candidate.trim().split(/\s+/)[0];
    }).filter(Boolean);
  }

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

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

  // ========== Extraction 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], img[src], img[data-src], img[data-original], img[srcset], source[srcset]');
      for (const el of candidates) {
        const raws = [el.getAttribute('href'), el.getAttribute('data-original'),
          el.getAttribute('data-src'), el.getAttribute('src')];
        raws.push.apply(raws, srcsetURLs(el.getAttribute('srcset')));
        for (const raw of raws) {
          const candidate = signedBigURL(raw, sourceUrl);
          if (candidate) { return candidate; }
        }
      }
    } catch (e) {}

    // URLs éventuellement placées dans un script au lieu d'un attribut HTML.
    const normalized = html.replace(/\\\//g, '/').replace(/&amp;/g, '&');
    const matches = normalized.match(/https?:\/\/[^"'<>\s\\]+/gi) || [];
    for (const raw of matches) {
      const candidate = signedBigURL(raw, sourceUrl);
      if (candidate) { return candidate; }
    }
    return '';
  }

  // ========== Identité des images et retour à la miniature ==========
  function pictureSources(img) {
    const picture = img.parentElement;
    return picture && picture.tagName === 'PICTURE' ? Array.from(picture.querySelectorAll('source')) : [];
  }

  function imageSignature(img, anchor) {
    return JSON.stringify([anchor.href, IMAGE_ATTRS.map(function(attr) { return img.getAttribute(attr); }),
      pictureSources(img).map(function(el) { return [el.getAttribute('srcset'), el.getAttribute('media'), el.getAttribute('type')]; })]);
  }

  function isCurrent(state) {
    return imageStates.get(state.img) === state && state.img.isConnected && state.anchor.isConnected &&
      state.img.closest('a') === state.anchor && imageSignature(state.img, state.anchor) === state.signature;
  }

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

  function saveImage(img) {
    return {
      attrs: IMAGE_ATTRS.map(function(attr) { return [attr, img.getAttribute(attr)]; }),
      sources: pictureSources(img).map(function(el) { return [el, el.getAttribute('srcset')]; })
    };
  }

  function restoreAttribute(el, attr, value) {
    if (value === null) { el.removeAttribute(attr); }
    else { el.setAttribute(attr, value); }
  }

  function applyBigUrl(state, bigUrl) {
    if (!isCurrent(state)) { return; }
    const img = state.img;
    const finalUrl = ensureNoht(bigUrl);
    if (state.cleanup) { state.cleanup(); }

    function cleanup() {
      img.removeEventListener('load', onLoad);
      img.removeEventListener('error', onError);
    }
    function onLoad() {
      if (isCurrent(state) && img.complete && img.naturalWidth > 0) { cleanup(); }
    }
    function onError() {
      if (!isCurrent(state) || !img.complete || img.naturalWidth > 0) { return; }
      cleanup();
      state.original.attrs.forEach(function(entry) { restoreAttribute(img, entry[0], entry[1]); });
      state.original.sources.forEach(function(entry) {
        if (entry[0].parentElement === img.parentElement) { restoreAttribute(entry[0], 'srcset', entry[1]); }
      });
      state.signature = imageSignature(img, state.anchor);
      state.status = 'failed';
      if (state.fullview && urlCache.get(state.fullview) === bigUrl) { urlCache.delete(state.fullview); }
      // Une conversion directe peut échouer ; tenter alors le lien HTML une fois.
      if (state.fullview && !state.fullviewAttempted) { requestFullview(state.fullview, state); }
    }

    state.cleanup = cleanup;
    state.finalUrl = finalUrl;
    state.status = 'applied';
    img.addEventListener('load', onLoad);
    img.addEventListener('error', onError);
    setOptimizedImageFlags(img);
    img.removeAttribute('srcset');
    pictureSources(img).forEach(function(el) { el.removeAttribute('srcset'); });
    ['data-src', 'data-original'].forEach(function(attr) {
      if (img.hasAttribute(attr)) { img.setAttribute(attr, finalUrl); }
    });
    img.src = finalUrl;
    state.signature = imageSignature(img, state.anchor);
  }

  // ========== Requêtes FireMonkey 3.x, six simultanées au maximum ==========
  async function doXFetchText(url) {
    if (typeof GM === 'undefined' || !GM || typeof GM.xmlHttpRequest !== 'function') {
      return { html: '', retryable: false };
    }
    try {
      const response = await GM.xmlHttpRequest({
        method: 'GET', url: url, timeout: CONFIG.FETCH_TIMEOUT_MS, responseType: 'text'
      });
      if (!response) { return { html: '', retryable: true }; }
      const status = Number(response.status) || 0;
      if (status < 200 || status >= 300) {
        return { html: '', retryable: status === 0 || status === 408 || status === 429 || status >= 500 };
      }
      return { html: response.responseText || (typeof response.response === 'string' ? response.response : '') || '', retryable: true };
    } catch (e) {
      return { html: '', retryable: true };
    }
  }

  function pruneJob(job) {
    job.images.forEach(function(state) {
      if (!isCurrent(state)) {
        job.images.delete(state);
        if (imageStates.get(state.img) === state) { imageStates.delete(state.img); }
        if (state.cleanup) { state.cleanup(); }
      }
    });
    return job.images.size > 0;
  }

  function processRequestQueue() {
    while (requestQueue.length && activeRequests < CONFIG.MAX_CONCURRENT_REQUESTS) {
      const item = requestQueue.shift();
      if (!pruneJob(item.job)) {
        item.resolve({ html: '', retryable: false });
        continue;
      }
      activeRequests++;
      doXFetchText(item.job.url)
        .then(item.resolve, function() { item.resolve({ html: '', retryable: true }); })
        .finally(function() { activeRequests--; processRequestQueue(); });
    }
  }

  function finishJob(job, bigUrl) {
    if (jobs.get(job.url) === job) { jobs.delete(job.url); }
    if (job.timer !== null) { clearTimeout(job.timer); }
    const images = Array.from(job.images);
    job.images.clear();
    images.forEach(function(state) {
      if (!isCurrent(state)) { return; }
      if (bigUrl) { applyBigUrl(state, bigUrl); }
      else { state.status = 'failed'; }
    });
  }

  function runJob(job) {
    job.timer = null;
    if (!pruneJob(job)) { finishJob(job, ''); return; }
    job.requesting = true;
    new Promise(function(resolve) {
      requestQueue.push({ job: job, resolve: resolve });
      processRequestQueue();
    }).then(function(result) {
      job.requesting = false;
      const bigUrl = extractBigUrl(result.html, job.url);
      if (bigUrl) {
        cacheSet(job.url, bigUrl);
        finishJob(job, bigUrl);
      } else if (!pruneJob(job) || !result.retryable || job.retries >= CONFIG.MAX_RETRIES) {
        finishJob(job, '');
      } else {
        const delay = CONFIG.RETRY_DELAY_MS * Math.pow(2, job.retries++);
        job.timer = setTimeout(function() { runJob(job); }, delay);
      }
    });
  }

  function requestFullview(url, state) {
    state.fullviewAttempted = true;
    state.status = 'pending';
    const cached = cacheGet(url);
    if (cached) { applyBigUrl(state, cached); return; }
    const existing = jobs.get(url);
    if (existing) { existing.images.add(state); return; }
    const job = { url: url, images: new Set([state]), retries: 0, timer: null, requesting: false };
    jobs.set(url, job);
    runJob(job);
  }

  function pruneJobs() {
    jobs.forEach(function(job) {
      if (!pruneJob(job) && !job.requesting) { finishJob(job, ''); }
    });
  }

  // ========== Traitement des images d'une ancre ==========
  function processImage(anchor, img) {
    const previous = imageStates.get(img);
    if (previous && isCurrent(previous)) { return; }
    if (previous && previous.cleanup) { previous.cleanup(); }

    const href = anchor.href;
    const fullview = fullviewURL(href);
    let direct = signedBigURL(href);
    // Un nouveau lien ne doit pas réutiliser la grande image du lien précédent.
    const changedLink = previous && previous.href !== href && img.getAttribute('src') === previous.finalUrl;
    if (!changedLink) {
      const raws = [img.getAttribute('data-original'), img.getAttribute('data-src'),
        img.currentSrc, img.getAttribute('src')];
      raws.push.apply(raws, srcsetURLs(img.getAttribute('srcset')));
      for (const raw of raws) {
        if (previous && raw === previous.finalUrl) { continue; }
        // La source courante peut être en retard après une modification de src.
        if (raw === img.currentSrc && raw !== img.getAttribute('src') && !img.hasAttribute('srcset') && !pictureSources(img).length) { continue; }
        const candidate = thumbnailToBig(raw) || signedBigURL(raw);
        if (candidate) { direct = candidate; break; }
      }
    }
    if (!direct && !fullview) { return; }

    const state = {
      img: img, anchor: anchor, href: href, fullview: fullview,
      signature: imageSignature(img, anchor), original: saveImage(img),
      status: 'pending', finalUrl: '', fullviewAttempted: false, cleanup: null
    };
    imageStates.set(img, state);
    setOptimizedImageFlags(img);
    if (direct) { applyBigUrl(state, direct); }
    else { requestFullview(fullview, state); }
  }

  function processAnchor(anchor) {
    if (!anchor || !anchor.isConnected) { return; }
    let images = anchor.querySelectorAll('img.postImg');
    if (!images.length) { images = anchor.querySelectorAll('img'); }
    for (const img of images) { processImage(anchor, img); }
  }

  function scanRoot(root) {
    if (!root || root.nodeType !== 1 || !root.isConnected) { return; }
    const parent = root.closest(ANCHORS);
    if (parent) { processAnchor(parent); }
    const anchors = root.querySelectorAll(ANCHORS);
    for (const anchor of anchors) { processAnchor(anchor); }
  }

  // ========== Spoilers : une seule file, 25 clics par frame, 1200 au total ==========
  function stepSpoilers() {
    spoilerScheduled = false;
    let count = 0;
    for (const head of spoilerQueue) {
      if (count++ >= CONFIG.SPOILER_CLICK_CHUNK) { break; }
      spoilerQueue.delete(head);
      if (!head.isConnected || !head.matches('.sp-head:not(.unfolded)') || expandedSpoilers.has(head)) { continue; }
      expandedSpoilers.add(head);
      spoilerClicks++;
      try { head.click(); } catch (e) {}
      if (spoilerClicks >= CONFIG.MAX_SPOILERS_CLICK) { spoilerQueue.clear(); break; }
    }
    if (spoilerQueue.size) { spoilerScheduled = true; requestAnimationFrame(stepSpoilers); }
  }

  function expandSpoilersIn(root) {
    if (!CONFIG.EXPAND_SPOILERS || !root || spoilerClicks >= CONFIG.MAX_SPOILERS_CLICK) { return; }
    const spoilers = [];
    if (root.matches('.sp-head:not(.unfolded)')) { spoilers.push(root); }
    spoilers.push.apply(spoilers, root.querySelectorAll('.sp-head:not(.unfolded)'));
    for (const head of spoilers) {
      if (spoilerClicks + spoilerQueue.size >= CONFIG.MAX_SPOILERS_CLICK) { break; }
      if (!expandedSpoilers.has(head)) { spoilerQueue.add(head); }
    }
    if (spoilerQueue.size && !spoilerScheduled) {
      spoilerScheduled = true;
      requestAnimationFrame(stepSpoilers);
    }
  }

  // ========== Scan groupé et mutations dynamiques ==========
  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 (const root of nodes) { scanRoot(root); }
      pruneJobs();
    }, CONFIG.SCAN_DEBOUNCE_MS);
  }

  const observer = new MutationObserver(function(mutations) {
    let removed = false;
    for (const mutation of mutations) {
      if (mutation.type === 'childList') {
        if (mutation.removedNodes.length) { removed = true; }
        for (const node of mutation.addedNodes) {
          if (node.nodeType !== 1) { continue; }
          enqueueScan(node);
          expandSpoilersIn(node);
        }
      } else {
        const target = mutation.target;
        if (target.tagName === 'IMG' || target.tagName === 'SOURCE' || target.tagName === 'A') {
          const anchor = target.closest(ANCHORS);
          if (anchor) { enqueueScan(anchor); }
        }
      }
    }
    if (removed) { pruneJobs(); }
  });

  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', 'media', 'type']
    });
  }

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