Pornolab Image Previewer

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey, το Greasemonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

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

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Violentmonkey για να εγκαταστήσετε αυτόν τον κώδικα.

θα χρειαστεί να εγκαταστήσετε μια επέκταση όπως το Tampermonkey ή το Userscripts για να εγκαταστήσετε αυτόν τον κώδικα.

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

Θα χρειαστεί να εγκαταστήσετε μια επέκταση διαχείρισης κώδικα χρήστη για να εγκαταστήσετε αυτόν τον κώδικα.

(Έχω ήδη έναν διαχειριστή κώδικα χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

Advertisement:

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(Έχω ήδη έναν διαχειριστή στυλ χρήστη, επιτρέψτε μου να τον εγκαταστήσω!)

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);
    }
})();