Предпросмотр изображений из постов Pornolab при наведении на ссылки тем в трекере и разделах форума.
// ==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);
}
})();