Pornolab image preview - improved

Preview torrent images on hover in tracker listing, with cached topic parsing and faster image loading

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

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

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

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

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

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

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

// ==UserScript==
// @name         Pornolab image preview - improved
// @description  Preview torrent images on hover in tracker listing, with cached topic parsing and faster image loading
// @namespace    https://pornolab.net/forum/index.php
// @version      0.4
// @author       tobij12 - pingu2, revised
// @match        https://pornolab.net/forum/tracker.php*
// @grant        GM_xmlhttpRequest
// @license MIT
// @connect      *
// ==/UserScript==

(function () {
    'use strict';

    /*
     * Main performance changes:
     * 1. Topic pages are fetched and parsed once per torrent URL.
     * 2. Mouse wheel navigation reuses cached image list instead of fetching topic again.
     * 3. Direct image URLs are assigned directly to <img src>, letting the browser cache/decode.
     * 4. Host-page image fetches use blob object URLs instead of base64 strings.
     * 5. Preview state is per link, not global.
     */

    const DEBUG = false;

    const PREVIEW_LEFT = '350px';
    const DEFAULT_PREVIEW_HEIGHT = 400;
    const MIN_PREVIEW_HEIGHT = 200;
    const MAX_TOPIC_CACHE_SIZE = 300;
    const PREFETCH_NEXT_IMAGE = true;

    const blockedSources = [
        'vsexshop',
        'static.pornolab.net',
        'yadro',
        'vpipi',
        '9bee784100d7c6108d51fd70e9b79a50.gif',
        'nodrink',
        'rimg',
        '9ac78b9bb3e82339391d223a64daf18f',
        '4f9a8a86a785326a0a3d1560404a6fdc',
        '73ea7145a1b7d011589c849c5391c7b6',
        '5772513e239a6a8ee48af36544313a06'
    ];

    const topicCache = new Map();       // topicUrl -> Promise<ImageItem[]>
    const resolvedImageCache = new Map(); // cacheKey -> Promise<string|null>, final image URL/object URL
    const directImagePreloadCache = new Map(); // direct image URL -> Promise<boolean>

    const stateByLink = new WeakMap();
    const knownLinks = new Set();

    const links = document.querySelectorAll('.med .tLink');

    injectStyles();

    document.addEventListener('keydown', function (e) {
        if (e.key === 'Escape') {
            closeAllPreviews();
        }
    });

    links.forEach(setupLink);

    function setupLink(el) {
        const state = getState(el);
        knownLinks.add(el);

        // Helps absolute positioning behave more predictably.
        if (!el.style.position) {
            el.style.position = 'relative';
        }

        el.addEventListener('mouseenter', async function () {
            state.opened = true;
            state.index = 0;
            state.requestToken++;

            const token = state.requestToken;
            const topicUrl = normalizeUrl(el.href, location.href);

            showSpinner(el, 'topic page');

            try {
                const images = await getTopicImagesCached(topicUrl);

                if (!isCurrent(el, token)) return;

                state.images = images;
                state.index = 0;

                if (!images.length) {
                    showMessage(el, 'No preview images found');
                    return;
                }

                await showImageAtCurrentIndex(el, token);
            } catch (err) {
                log('Topic load failed', err);
                if (isCurrent(el, token)) {
                    showMessage(el, 'Preview load failed');
                }
            }
        });

        el.addEventListener('wheel', async function (e) {
            if (!state.opened) return;

            e.preventDefault();
            e.stopPropagation();

            const token = ++state.requestToken;

            try {
                if (!state.images) {
                    const topicUrl = normalizeUrl(el.href, location.href);
                    state.images = await getTopicImagesCached(topicUrl);
                }

                if (!state.images.length) return;

                if (e.deltaY < 0) {
                    state.index = Math.max(0, state.index - 1);
                } else if (e.deltaY > 0) {
                    state.index = Math.min(state.images.length - 1, state.index + 1);
                }

                await showImageAtCurrentIndex(el, token);
            } catch (err) {
                log('Wheel image load failed', err);
            }

            return false;
        }, { passive: false });

        const row = el.closest('tr');
        if (row) {
            row.addEventListener('mouseout', function (event) {
                const related = event.relatedTarget;

                // Ignore movement inside the same row.
                if (related && row.contains(related)) return;

                closePreview(el);
            });
        }
    }

    function getState(el) {
        let state = stateByLink.get(el);

        if (!state) {
            state = {
                opened: false,
                index: 0,
                requestToken: 0,
                images: null
            };

            stateByLink.set(el, state);
        }

        return state;
    }

    function isCurrent(el, token) {
        const state = getState(el);
        return state.opened && state.requestToken === token;
    }

    async function showImageAtCurrentIndex(el, token) {
        const state = getState(el);
        const images = state.images || [];

        if (!images.length) return;

        const item = images[state.index];
        if (!item || !item.thumbUrl) return;

        const total = images.length;
        const index = state.index;

        const position = calculatePreviewPosition(el);
        const hostLabel = getHostLabel(item.thumbUrl, item.parentUrl);

        // Show thumbnail/direct source immediately while resolving a better image.
        showPreview(el, {
            src: item.thumbUrl,
            index,
            total,
            height: position.height,
            topMargin: position.topMargin,
            loadingText: `Loading from ${hostLabel}...`,
            showSpinner: true
        });

        try {
            const finalSrc = await resolveDisplayImageUrlCached(item);

            if (!isCurrent(el, token)) return;
            if (index !== getState(el).index) return;

            if (finalSrc) {
                showPreview(el, {
                    src: finalSrc,
                    index,
                    total,
                    height: position.height,
                    topMargin: position.topMargin,
                    loadingText: '',
                    showSpinner: false
                });
            } else {
                showPreview(el, {
                    src: item.thumbUrl,
                    index,
                    total,
                    height: position.height,
                    topMargin: position.topMargin,
                    loadingText: '',
                    showSpinner: false
                });
            }

            if (PREFETCH_NEXT_IMAGE) {
                prefetchNearby(images, index);
            }
        } catch (err) {
            log('Image resolve failed', err);

            if (!isCurrent(el, token)) return;

            showPreview(el, {
                src: item.thumbUrl,
                index,
                total,
                height: position.height,
                topMargin: position.topMargin,
                loadingText: 'Using thumbnail',
                showSpinner: false
            });
        }
    }

    function prefetchNearby(images, index) {
        const next = images[index + 1];
        if (!next) return;

        resolveDisplayImageUrlCached(next).catch(() => {});
    }

    function closePreview(el) {
        const state = getState(el);
        state.opened = false;
        state.index = 0;
        state.requestToken++;
        removePreviewNodes(el);
    }

    function closeAllPreviews() {
        knownLinks.forEach(closePreview);
        document.querySelectorAll('.appendedHoverImgContainer').forEach(n => n.remove());
    }

    function removePreviewNodes(el) {
        el.querySelectorAll('.appendedHoverImgContainer').forEach(n => n.remove());
    }

    function getTopicImagesCached(topicUrl) {
        if (topicCache.has(topicUrl)) {
            return topicCache.get(topicUrl);
        }

        const promise = fetchText(topicUrl)
            .then(html => parseTopicImages(html, topicUrl))
            .catch(err => {
                topicCache.delete(topicUrl);
                throw err;
            });

        topicCache.set(topicUrl, promise);
        trimMap(topicCache, MAX_TOPIC_CACHE_SIZE);

        return promise;
    }

    function parseTopicImages(html, topicUrl) {
        const doc = new DOMParser().parseFromString(html, 'text/html');

        // The attached script scopes to .post_body and reads var.postImg, which is cleaner
        // than scanning the entire parsed HTML.
        const root = doc.querySelector('.post_body') || doc;

        const candidates = Array.from(root.querySelectorAll('var.postImg, .postImg'));

        const images = [];

        for (const node of candidates) {
            const thumbUrlRaw = node.getAttribute('title') || node.title || '';
            if (!thumbUrlRaw) continue;

            const thumbUrl = normalizeUrl(thumbUrlRaw, topicUrl);
            if (!thumbUrl) continue;

            if (isBlockedSource(thumbUrl)) continue;

            const parentAnchor = node.closest('a');
            const parentUrl = parentAnchor
                ? normalizeUrl(parentAnchor.getAttribute('href') || parentAnchor.href, topicUrl)
                : null;

            // Keep your original row1 intent, but do not require it if we're already scoped
            // to .post_body. Some pages may not preserve .row1 exactly when parsed.
            const insideLikelyContent =
                node.closest('.row1') ||
                node.closest('.post_body') ||
                root.classList?.contains('post_body');

            if (!insideLikelyContent) continue;

            images.push({
                thumbUrl,
                parentUrl
            });
        }

        return dedupeImages(images);
    }

    function dedupeImages(images) {
        const seen = new Set();
        const result = [];

        for (const item of images) {
            const key = `${item.thumbUrl}|${item.parentUrl || ''}`;
            if (seen.has(key)) continue;

            seen.add(key);
            result.push(item);
        }

        return result;
    }

    function isBlockedSource(url) {
        return blockedSources.some(blocked => url.includes(blocked));
    }

    async function resolveDisplayImageUrlCached(item) {
        const cacheKey = `${item.thumbUrl}|${item.parentUrl || ''}`;

        if (resolvedImageCache.has(cacheKey)) {
            return resolvedImageCache.get(cacheKey);
        }

        const promise = resolveDisplayImageUrl(item)
            .catch(err => {
                resolvedImageCache.delete(cacheKey);
                throw err;
            });

        resolvedImageCache.set(cacheKey, promise);
        return promise;
    }

    async function resolveDisplayImageUrl(item) {
        let validSource = item.thumbUrl;
        const parentUrl = item.parentUrl;

        if (!validSource) return null;

        // GIFs and most direct URLs can be shown directly. No base64 needed.
        if (validSource.includes('.gif')) {
            return validSource;
        }

        // Fast direct URL rewrites.
        if (validSource.includes('imgbox') && validSource.includes('thumb')) {
            return validSource
                .replace('thumbs', 'images')
                .replace('_t', '_o');
        }

        if (validSource.includes('imgdrive') && validSource.includes('small')) {
            return validSource.replace('small', 'big');
        }

        if (validSource.includes('freescreens.ru') && validSource.includes('thumb')) {
            return validSource
                .replace('freescreens.', 'picforall.')
                .replace('-thumb', '');
        }

        // Hosts that usually need the image view page parsed.
        if (validSource.includes('fastpic.org') && parentUrl) {
            return fetchFastpicBigImageObjectUrlCached(parentUrl);
        }

        if (validSource.includes('imgbox') && parentUrl) {
            return fetchHostImageObjectUrlCached(parentUrl);
        }

        if (validSource.includes('imagevenue.com') && validSource.includes('thumbs') && parentUrl) {
            return fetchHostImageObjectUrlCached(parentUrl);
        }

        if (validSource.includes('turboimg.net') && validSource.includes('/t/') && parentUrl) {
            return fetchHostImageObjectUrlCached(parentUrl);
        }

        if (validSource.includes('picshick') && validSource.includes('/th/') && parentUrl) {
            return fetchHostImageObjectUrlCached(parentUrl);
        }

        if (validSource.includes('picturelol') && validSource.includes('/th/') && parentUrl) {
            return fetchHostImageObjectUrlCached(parentUrl);
        }

        // Normal direct image URL.
        // Browser handles caching/decoding better than a userscript base64 cache.
        return validSource;
    }

    function fetchFastpicBigImageObjectUrlCached(viewPageUrl) {
        const key = `fastpic:${viewPageUrl}`;

        if (resolvedImageCache.has(key)) {
            return resolvedImageCache.get(key);
        }

        const promise = fetchFastpicBigImageObjectUrl(viewPageUrl)
            .catch(err => {
                resolvedImageCache.delete(key);
                throw err;
            });

        resolvedImageCache.set(key, promise);
        return promise;
    }

    function fetchHostImageObjectUrlCached(viewPageUrl) {
        const key = `host:${viewPageUrl}`;

        if (resolvedImageCache.has(key)) {
            return resolvedImageCache.get(key);
        }

        const promise = fetchHostImageObjectUrl(viewPageUrl)
            .catch(err => {
                resolvedImageCache.delete(key);
                throw err;
            });

        resolvedImageCache.set(key, promise);
        return promise;
    }

    async function fetchFastpicBigImageObjectUrl(viewPageUrl) {
        const response = await gmRequest({
            method: 'GET',
            url: viewPageUrl,
            responseType: 'blob'
        });

        const contentType = getContentType(response);

        if (contentType.includes('image/')) {
            return URL.createObjectURL(response.response);
        }

        if (!contentType.includes('text/html')) {
            return null;
        }

        const html = await blobToText(response.response);
        const doc = new DOMParser().parseFromString(html, 'text/html');

        const images = Array.from(doc.querySelectorAll('img'));
        const validImage = images.find(img =>
            img.src.includes('md5=') && img.src.includes('expires=')
        );

        if (!validImage) return null;

        return fetchImageAsObjectUrl(validImage.src);
    }

    async function fetchHostImageObjectUrl(viewPageUrl) {
        const response = await gmRequest({
            method: 'GET',
            url: viewPageUrl,
            responseType: 'blob'
        });

        const contentType = getContentType(response);

        if (contentType.includes('image/')) {
            return URL.createObjectURL(response.response);
        }

        if (!contentType.includes('text/html')) {
            return null;
        }

        const html = await blobToText(response.response);
        const doc = new DOMParser().parseFromString(html, 'text/html');

        const images = Array.from(doc.querySelectorAll('img'));

        const validImage = images.find(img => {
            const src = img.src || '';

            return (
                (src.includes('turboimg.net') && src.includes('/sp/')) ||
                src.includes('cdn-images.imagevenue') ||
                src.includes('picshick.com/i') ||
                src.includes('picturelol.com/i') ||
                src.includes('images2.imgbox.com') ||
                src.includes('images.imgbox.com')
            );
        });

        if (!validImage) return null;

        return fetchImageAsObjectUrl(validImage.src);
    }

    async function fetchImageAsObjectUrl(url) {
        const response = await gmRequest({
            method: 'GET',
            url,
            responseType: 'blob'
        });

        const contentType = getContentType(response);

        if (!contentType.includes('image/')) {
            // Some hosts omit content-type. Still try if blob exists.
            if (!response.response) return null;
        }

        return URL.createObjectURL(response.response);
    }

    function fetchText(url) {
        return gmRequest({
            method: 'GET',
            url,
            responseType: 'text',
            headers: {
                Referer: url,
                'User-Agent': navigator.userAgent
            }
        }).then(response => response.responseText || response.response || '');
    }

    function gmRequest(options) {
        return new Promise((resolve, reject) => {
            GM_xmlhttpRequest({
                method: options.method || 'GET',
                url: options.url,
                responseType: options.responseType,
                headers: options.headers || undefined,
                timeout: options.timeout || 30000,
                onload: response => {
                    if (response.status >= 200 && response.status < 400) {
                        resolve(response);
                    } else {
                        reject(new Error(`HTTP ${response.status} for ${options.url}`));
                    }
                },
                ontimeout: () => reject(new Error(`Timeout for ${options.url}`)),
                onerror: err => reject(err)
            });
        });
    }

    function getContentType(response) {
        const headers = (response.responseHeaders || '').toLowerCase();

        const line = headers
            .split(/\r?\n/)
            .find(header => header.startsWith('content-type:'));

        return line || '';
    }

    function blobToText(blob) {
        if (!blob) return Promise.resolve('');

        // Blob.text() is cleaner, but FileReader is safer in older userscript contexts.
        return new Promise((resolve, reject) => {
            const reader = new FileReader();
            reader.onload = () => resolve(reader.result || '');
            reader.onerror = reject;
            reader.readAsText(blob);
        });
    }

    function showPreview(el, options) {
        const container = getPreviewContainer(el);

        container.style.left = PREVIEW_LEFT;
        container.style.marginTop = `${options.topMargin}px`;
        container.style.height = `${options.height}px`;

        const img = container.querySelector('.preview-img');
        const label = container.querySelector('.imageCounterLabel');
        const spinnerRow = container.querySelector('.preview-loading');

        label.textContent = `Image ${options.index + 1}/${options.total}`;

        if (options.showSpinner) {
            spinnerRow.style.display = 'flex';
            spinnerRow.querySelector('.spinner-text').textContent = options.loadingText || '';
        } else {
            spinnerRow.style.display = 'none';
        }

        if (img.src !== options.src) {
            img.src = options.src;
        }
    }

    function getPreviewContainer(el) {
        let container = el.querySelector('.appendedHoverImgContainer');

        if (container) return container;

        container = document.createElement('div');
        container.className = 'appendedHoverImgContainer';

        container.style.cssText = `
            position: absolute;
            left: ${PREVIEW_LEFT};
            margin-top: 15px;
            height: ${DEFAULT_PREVIEW_HEIGHT}px;
            width: auto;
            z-index: 9999;
            pointer-events: none;
        `;

        const img = document.createElement('img');
        img.className = 'preview-img';
        img.decoding = 'async';
        img.loading = 'eager';
        img.style.cssText = `
            height: 100%;
            width: auto;
            display: block;
            background: rgba(0, 0, 0, 0.25);
            box-shadow: 0 4px 20px rgba(0,0,0,0.45);
        `;

        const label = document.createElement('div');
        label.className = 'imageCounterLabel';
        label.style.cssText = `
            position: absolute;
            top: 5px;
            left: 5px;
            color: #fff;
            font-weight: normal;
            font-family: verdana, sans-serif;
            background-color: rgba(0, 0, 0, 0.6);
            padding: 2px 6px;
            font-size: 12px;
            border-radius: 4px;
            pointer-events: none;
        `;

        const loading = document.createElement('div');
        loading.className = 'preview-loading';
        loading.style.cssText = `
            position: absolute;
            left: 5px;
            bottom: 5px;
            display: flex;
            align-items: center;
            gap: 8px;
            padding: 3px 6px;
            border-radius: 4px;
            background: rgba(0,0,0,0.6);
        `;

        const spinner = document.createElement('div');
        spinner.className = 'spinner';

        const spinnerText = document.createElement('span');
        spinnerText.className = 'spinner-text';

        loading.appendChild(spinner);
        loading.appendChild(spinnerText);

        container.appendChild(img);
        container.appendChild(label);
        container.appendChild(loading);

        el.appendChild(container);

        return container;
    }

    function showSpinner(el, host) {
        const position = calculatePreviewPosition(el);

        showPreview(el, {
            src: transparentPixel(),
            index: 0,
            total: 1,
            height: position.height,
            topMargin: position.topMargin,
            loadingText: host ? `Loading from ${host}...` : 'Loading...',
            showSpinner: true
        });
    }

    function showMessage(el, message) {
        const position = calculatePreviewPosition(el);
        const container = getPreviewContainer(el);

        container.style.left = PREVIEW_LEFT;
        container.style.marginTop = `${position.topMargin}px`;
        container.style.height = '32px';

        const img = container.querySelector('.preview-img');
        const label = container.querySelector('.imageCounterLabel');
        const spinnerRow = container.querySelector('.preview-loading');

        img.src = transparentPixel();
        label.textContent = message;
        spinnerRow.style.display = 'none';
    }

    function calculatePreviewPosition(el) {
        const y = el.getBoundingClientRect().top;
        const windowHeight = window.innerHeight;

        let height = DEFAULT_PREVIEW_HEIGHT;
        let topMargin = 15;

        if (windowHeight < y + 1800 - 15) {
            height = windowHeight - y - 45;

            if (height < MIN_PREVIEW_HEIGHT) {
                height = MIN_PREVIEW_HEIGHT;
                topMargin = windowHeight - y - 225;
            }
        }

        return { height, topMargin };
    }

    function normalizeUrl(url, baseUrl) {
        if (!url) return null;

        try {
            return new URL(url, baseUrl || location.href).href;
        } catch {
            return null;
        }
    }

    function getHostLabel(thumbUrl, parentUrl) {
        try {
            if (parentUrl) return new URL(parentUrl).hostname;
            return new URL(thumbUrl).hostname;
        } catch {
            return 'host';
        }
    }

    function trimMap(map, maxSize) {
        while (map.size > maxSize) {
            const firstKey = map.keys().next().value;
            map.delete(firstKey);
        }
    }

    function injectStyles() {
        const style = document.createElement('style');

        style.textContent = `
            .spinner {
                border: 5px solid rgba(255,255,255,0.18);
                border-left-color: #aaa;
                border-radius: 50%;
                width: 14px;
                height: 14px;
                animation: pornolabPreviewSpin 1s linear infinite;
                flex: 0 0 auto;
            }

            .spinner-text {
                font-size: 13px;
                color: #eee;
                font-weight: normal;
                font-family: sans-serif;
                white-space: nowrap;
            }

            @keyframes pornolabPreviewSpin {
                to { transform: rotate(360deg); }
            }
        `;

        document.head.appendChild(style);
    }

    function transparentPixel() {
        return 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
    }

    function log(...args) {
        if (DEBUG) {
            console.log('[Pornolab preview]', ...args);
        }
    }

})();