Pornolab Image Previewer

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

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

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

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

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

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

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

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

Advertisement:

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

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

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

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

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

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

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

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