☰

pornolabFullSizeImages

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

您需要先安裝使用者腳本管理器擴展,如 Tampermonkey、Greasemonkey 或 Violentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 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(); }
})();