Pornolab Image Previewer

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

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

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 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);
    }
})();