Pornolab Image Previewer

Preview post images on Pornolab tracker and forum pages by hovering over topic links.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Advertisement:

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

Advertisement:

// ==UserScript==
// @name              Pornolab Image Previewer
// @name:ru           Pornolab Предпросмотр Изображений
// @namespace         http://tampermonkey.net/
// @version           1.0.1
// @description       Preview post images on Pornolab tracker and forum pages by hovering over topic links.
// @description:ru    Предпросмотр изображений из постов Pornolab при наведении на ссылки тем в трекере и разделах форума.
// @author            DrkDev1l
// @match             https://pornolab.net/forum/tracker.php*
// @match             https://pornolab.net/forum/viewforum.php*
// @grant             GM_xmlhttpRequest
// @connect           pornolab.net
// @connect           *
// @icon              https://www.google.com/s2/favicons?domain=pornolab.net&sz=64
// @license           MIT
// ==/UserScript==

(function () {
    'use strict';

    const EXCLUDE_LIST = [
        'thumb_clickview.png',
        'no_image.jpg'
    ];

    const SEARCH_RESULTS_SELECTOR = '#search-results';
    const LINK_SELECTOR = 'a[href*="viewtopic.php?t="]';
    const RESULT_LINK_SELECTOR = 'a.tLink[href*="viewtopic.php?t="], a[href*="viewtopic.php?t="]';
    const HITBOX_CLASS = 'pl-preview-hitbox';
    const HITBOX_SELECTOR = `.${HITBOX_CLASS}`;

    const cache = new Map();
    const queue = [];
    const pendingUrls = new Set();

    let activeRequests = 0;
    const MAX_CONCURRENT = 2;

    let activeHitbox = null;
    let activeLink = null;
    let activeUrl = null;

    let lastMouseX = 0;
    let lastMouseY = 0;

    let renderToken = 0;
    let posRaf = 0;

    const style = document.createElement('style');

    style.textContent = `
        ${SEARCH_RESULTS_SELECTOR} .${HITBOX_CLASS} {
            display: block;
            padding: 2px 0;
        }
    `;

    document.head.appendChild(style);

    const preview = document.createElement('div');

    Object.assign(preview.style, {
        position: 'fixed',
        display: 'none',
        zIndex: '100000',
        backgroundColor: '#1c1c1c',
        border: '1px solid #ff9000',
        boxShadow: '0 0 15px rgba(0,0,0,0.8)',
        padding: '2px',
        borderRadius: '4px',
        pointerEvents: 'none',
        color: '#ccc',
        fontSize: '11px',
        maxWidth: '550px',
        maxHeight: '95vh',
        boxSizing: 'border-box',
        overflow: 'hidden'
    });

    document.body.appendChild(preview);

    const getSearchResultsRoot = () => {
        return document.querySelector(SEARCH_RESULTS_SELECTOR);
    };

    const isInsideSearchResults = (element) => {
        const root = getSearchResultsRoot();

        return Boolean(root && element && root.contains(element));
    };

    const isBadImageUrl = (url) => {
        const lower = String(url || '').toLowerCase();

        return EXCLUDE_LIST.some(bad => lower.includes(bad.toLowerCase()));
    };

    const normalizeImageUrl = (url) => {
        let result = String(url || '')
            .trim()
            .replace(/&/g, '&')
            .replace(/^http:/, 'https:');

        if (result.startsWith('//')) {
            result = `https:${result}`;
        }

        if (!result.startsWith('http')) {
            result = new URL(result, 'https://pornolab.net/forum/').href;
        }

        return result;
    };

    const isActivePreview = (url) => {
        return activeUrl === url &&
            activeHitbox &&
            activeHitbox.isConnected &&
            isInsideSearchResults(activeHitbox) &&
            activeLink &&
            activeLink.isConnected;
    };

    const updatePos = (x, y) => {
        if (preview.style.display === 'none') return;

        const offset = 20;
        const margin = 10;

        const rect = preview.getBoundingClientRect();

        const width = Math.min(
            rect.width || preview.offsetWidth || 0,
            window.innerWidth - margin * 2
        );

        const height = Math.min(
            rect.height || preview.offsetHeight || 0,
            window.innerHeight - margin * 2
        );

        let posX = x + offset;
        let posY = y + offset;

        if (posX + width + margin > window.innerWidth) {
            posX = x - width - offset;
        }

        if (posY + height + margin > window.innerHeight) {
            posY = y - height - offset;
        }

        posX = Math.max(margin, Math.min(posX, window.innerWidth - width - margin));
        posY = Math.max(margin, Math.min(posY, window.innerHeight - height - margin));

        preview.style.left = `${Math.round(posX)}px`;
        preview.style.top = `${Math.round(posY)}px`;
    };

    const scheduleUpdatePos = () => {
        if (!activeUrl || preview.style.display === 'none') return;

        cancelAnimationFrame(posRaf);

        posRaf = requestAnimationFrame(() => {
            updatePos(lastMouseX, lastMouseY);
        });
    };

    const showPreview = () => {
        preview.style.display = 'flex';
        scheduleUpdatePos();
    };

    const hidePreview = () => {
        activeHitbox = null;
        activeLink = null;
        activeUrl = null;

        renderToken++;

        cancelAnimationFrame(posRaf);

        preview.style.display = 'none';
        preview.innerHTML = '';
    };

    const showPreviewMessage = (text) => {
        preview.innerHTML = `<div style="padding:5px; white-space:nowrap">${text}</div>`;
        showPreview();
    };

    const fetchImage = (url, priority = false) => {
        if (cache.has(url)) return;

        if (pendingUrls.has(url)) {
            if (priority) {
                const idx = queue.indexOf(url);

                if (idx > -1) {
                    queue.splice(idx, 1);
                    queue.unshift(url);
                }
            }

            return;
        }

        pendingUrls.add(url);

        if (priority) {
            queue.unshift(url);
        } else {
            queue.push(url);
        }

        processQueue();
    };

    const processQueue = () => {
        if (activeRequests >= MAX_CONCURRENT || queue.length === 0) return;

        const url = queue.shift();

        activeRequests++;

        GM_xmlhttpRequest({
            method: 'GET',
            url,

            onload: function (res) {
                try {
                    const html = res.responseText || '';
                    const bodyStart = html.indexOf('class="post_body"');

                    const content = bodyStart !== -1
                        ? html.substring(bodyStart, bodyStart + 18000)
                        : html;

                    const regex = /<(?:img|var)[^>]+(?:src|title|data-src)="([^"]+)"[^>]+class="[^"]*postImg[^"]*"|<(?:img|var)[^>]+class="[^"]*postImg[^"]*"[^>]+(?:src|title|data-src)="([^"]+)"/gi;

                    const matches = [];
                    let match;

                    while ((match = regex.exec(content)) !== null) {
                        const imageUrl = normalizeImageUrl(match[1] || match[2]);

                        if (!isBadImageUrl(imageUrl) && !matches.includes(imageUrl)) {
                            matches.push(imageUrl);
                        }
                    }

                    matches.sort((a, b) => {
                        const aIsJpg = /\.(jpe?g)(?:[?#].*)?$/i.test(a);
                        const bIsJpg = /\.(jpe?g)(?:[?#].*)?$/i.test(b);

                        return Number(bIsJpg) - Number(aIsJpg);
                    });

                    cache.set(url, matches.length > 0 ? matches : null);
                } catch (err) {
                    console.error('[PL Previewer] Parse failed:', err);
                    cache.set(url, null);
                } finally {
                    pendingUrls.delete(url);
                    activeRequests--;

                    if (isActivePreview(url)) {
                        renderPreview(url);
                    }

                    processQueue();
                }
            },

            onerror: () => {
                cache.set(url, null);
                pendingUrls.delete(url);
                activeRequests--;

                if (isActivePreview(url)) {
                    renderPreview(url);
                }

                processQueue();
            }
        });
    };

    const renderPreview = (url) => {
        if (!isActivePreview(url)) return;

        const imgList = cache.get(url);
        const token = ++renderToken;

        if (Array.isArray(imgList) && imgList.length > 0) {
            showPreviewMessage('Загрузка изображения...');

            const img = document.createElement('img');

            Object.assign(img.style, {
                maxWidth: '100%',
                maxHeight: 'calc(95vh - 10px)',
                display: 'block',
                objectFit: 'contain'
            });

            img.decoding = 'async';

            let attempt = 0;

            const tryNext = () => {
                if (token !== renderToken || !isActivePreview(url)) return;

                if (attempt >= imgList.length) {
                    showPreviewMessage('Нет доступных скриншотов');
                    return;
                }

                img.src = imgList[attempt];
                attempt++;
            };

            img.onerror = () => {
                tryNext();
            };

            img.onload = () => {
                if (token !== renderToken || !isActivePreview(url)) return;

                const finalSrc = (img.currentSrc || img.src || '').toLowerCase();

                if (isBadImageUrl(finalSrc) || !img.naturalWidth || !img.naturalHeight) {
                    tryNext();
                    return;
                }

                preview.innerHTML = '';
                preview.appendChild(img);

                showPreview();

                requestAnimationFrame(() => {
                    updatePos(lastMouseX, lastMouseY);
                });
            };

            tryNext();
            return;
        }

        if (imgList === null) {
            showPreviewMessage('Картинок нет');
            return;
        }

        showPreviewMessage('Загрузка...');
    };

    const observer = new IntersectionObserver((entries) => {
        entries.forEach(entry => {
            if (!entry.isIntersecting) return;

            const root = getSearchResultsRoot();

            if (!root || !root.contains(entry.target)) {
                observer.unobserve(entry.target);
                return;
            }

            const link = entry.target.querySelector(RESULT_LINK_SELECTOR);

            if (link) {
                fetchImage(link.href);
            }

            observer.unobserve(entry.target);
        });
    }, {
        rootMargin: '400px'
    });

    document.addEventListener('pointerover', (e) => {
        const hitbox = e.target.closest(HITBOX_SELECTOR);

        if (!hitbox) return;
        if (!isInsideSearchResults(hitbox)) return;
        if (activeHitbox === hitbox) return;

        const link = hitbox.querySelector(LINK_SELECTOR);

        if (!link) return;

        activeHitbox = hitbox;
        activeLink = link;
        activeUrl = link.href;

        lastMouseX = e.clientX;
        lastMouseY = e.clientY;

        if (!cache.has(activeUrl)) {
            fetchImage(activeUrl, true);
        }

        renderPreview(activeUrl);
    });

    document.addEventListener('pointermove', (e) => {
        if (!activeUrl) return;

        lastMouseX = e.clientX;
        lastMouseY = e.clientY;

        scheduleUpdatePos();
    });

    document.addEventListener('pointerout', (e) => {
        const hitbox = e.target.closest(HITBOX_SELECTOR);

        if (!hitbox) return;
        if (!isInsideSearchResults(hitbox)) return;

        const nextTarget = e.relatedTarget;

        if (nextTarget && hitbox.contains(nextTarget)) {
            return;
        }

        if (hitbox === activeHitbox) {
            hidePreview();
        }
    });

    window.addEventListener('resize', () => {
        scheduleUpdatePos();
    });

    window.addEventListener('scroll', () => {
        if (!activeHitbox) return;

        if (!activeHitbox.matches(':hover') || !isInsideSearchResults(activeHitbox)) {
            hidePreview();
        }
    }, {
        passive: true
    });

    const markHitboxes = () => {
        const root = getSearchResultsRoot();

        if (!root) {
            hidePreview();
            return;
        }

        const links = root.querySelectorAll(RESULT_LINK_SELECTOR);

        links.forEach(link => {
            const hitbox = link.closest('div') || link.parentElement;

            if (hitbox && root.contains(hitbox)) {
                hitbox.classList.add(HITBOX_CLASS);
            }
        });
    };

    const runInit = () => {
        const root = getSearchResultsRoot();

        if (!root) {
            hidePreview();
            return;
        }

        markHitboxes();

        const rows = root.querySelectorAll('#tor-tbl tbody tr, tbody tr, tr');

        rows.forEach(row => {
            observer.observe(row);
        });
    };

    if (document.readyState === 'complete') {
        runInit();
    } else {
        window.addEventListener('load', runInit);
    }
})();