Sleazy Fork is available in English.
Preview torrent images on hover in tracker listing, with cached topic parsing and faster image loading
// ==UserScript==
// @name Pornolab image preview - improved
// @description Preview torrent images on hover in tracker listing, with cached topic parsing and faster image loading
// @namespace https://pornolab.net/forum/index.php
// @version 0.4
// @author tobij12 - pingu2, revised
// @match https://pornolab.net/forum/tracker.php*
// @grant GM_xmlhttpRequest
// @license MIT
// @connect *
// ==/UserScript==
(function () {
'use strict';
/*
* Main performance changes:
* 1. Topic pages are fetched and parsed once per torrent URL.
* 2. Mouse wheel navigation reuses cached image list instead of fetching topic again.
* 3. Direct image URLs are assigned directly to <img src>, letting the browser cache/decode.
* 4. Host-page image fetches use blob object URLs instead of base64 strings.
* 5. Preview state is per link, not global.
*/
const DEBUG = false;
const PREVIEW_LEFT = '350px';
const DEFAULT_PREVIEW_HEIGHT = 400;
const MIN_PREVIEW_HEIGHT = 200;
const MAX_TOPIC_CACHE_SIZE = 300;
const PREFETCH_NEXT_IMAGE = true;
const blockedSources = [
'vsexshop',
'static.pornolab.net',
'yadro',
'vpipi',
'9bee784100d7c6108d51fd70e9b79a50.gif',
'nodrink',
'rimg',
'9ac78b9bb3e82339391d223a64daf18f',
'4f9a8a86a785326a0a3d1560404a6fdc',
'73ea7145a1b7d011589c849c5391c7b6',
'5772513e239a6a8ee48af36544313a06'
];
const topicCache = new Map(); // topicUrl -> Promise<ImageItem[]>
const resolvedImageCache = new Map(); // cacheKey -> Promise<string|null>, final image URL/object URL
const directImagePreloadCache = new Map(); // direct image URL -> Promise<boolean>
const stateByLink = new WeakMap();
const knownLinks = new Set();
const links = document.querySelectorAll('.med .tLink');
injectStyles();
document.addEventListener('keydown', function (e) {
if (e.key === 'Escape') {
closeAllPreviews();
}
});
links.forEach(setupLink);
function setupLink(el) {
const state = getState(el);
knownLinks.add(el);
// Helps absolute positioning behave more predictably.
if (!el.style.position) {
el.style.position = 'relative';
}
el.addEventListener('mouseenter', async function () {
state.opened = true;
state.index = 0;
state.requestToken++;
const token = state.requestToken;
const topicUrl = normalizeUrl(el.href, location.href);
showSpinner(el, 'topic page');
try {
const images = await getTopicImagesCached(topicUrl);
if (!isCurrent(el, token)) return;
state.images = images;
state.index = 0;
if (!images.length) {
showMessage(el, 'No preview images found');
return;
}
await showImageAtCurrentIndex(el, token);
} catch (err) {
log('Topic load failed', err);
if (isCurrent(el, token)) {
showMessage(el, 'Preview load failed');
}
}
});
el.addEventListener('wheel', async function (e) {
if (!state.opened) return;
e.preventDefault();
e.stopPropagation();
const token = ++state.requestToken;
try {
if (!state.images) {
const topicUrl = normalizeUrl(el.href, location.href);
state.images = await getTopicImagesCached(topicUrl);
}
if (!state.images.length) return;
if (e.deltaY < 0) {
state.index = Math.max(0, state.index - 1);
} else if (e.deltaY > 0) {
state.index = Math.min(state.images.length - 1, state.index + 1);
}
await showImageAtCurrentIndex(el, token);
} catch (err) {
log('Wheel image load failed', err);
}
return false;
}, { passive: false });
const row = el.closest('tr');
if (row) {
row.addEventListener('mouseout', function (event) {
const related = event.relatedTarget;
// Ignore movement inside the same row.
if (related && row.contains(related)) return;
closePreview(el);
});
}
}
function getState(el) {
let state = stateByLink.get(el);
if (!state) {
state = {
opened: false,
index: 0,
requestToken: 0,
images: null
};
stateByLink.set(el, state);
}
return state;
}
function isCurrent(el, token) {
const state = getState(el);
return state.opened && state.requestToken === token;
}
async function showImageAtCurrentIndex(el, token) {
const state = getState(el);
const images = state.images || [];
if (!images.length) return;
const item = images[state.index];
if (!item || !item.thumbUrl) return;
const total = images.length;
const index = state.index;
const position = calculatePreviewPosition(el);
const hostLabel = getHostLabel(item.thumbUrl, item.parentUrl);
// Show thumbnail/direct source immediately while resolving a better image.
showPreview(el, {
src: item.thumbUrl,
index,
total,
height: position.height,
topMargin: position.topMargin,
loadingText: `Loading from ${hostLabel}...`,
showSpinner: true
});
try {
const finalSrc = await resolveDisplayImageUrlCached(item);
if (!isCurrent(el, token)) return;
if (index !== getState(el).index) return;
if (finalSrc) {
showPreview(el, {
src: finalSrc,
index,
total,
height: position.height,
topMargin: position.topMargin,
loadingText: '',
showSpinner: false
});
} else {
showPreview(el, {
src: item.thumbUrl,
index,
total,
height: position.height,
topMargin: position.topMargin,
loadingText: '',
showSpinner: false
});
}
if (PREFETCH_NEXT_IMAGE) {
prefetchNearby(images, index);
}
} catch (err) {
log('Image resolve failed', err);
if (!isCurrent(el, token)) return;
showPreview(el, {
src: item.thumbUrl,
index,
total,
height: position.height,
topMargin: position.topMargin,
loadingText: 'Using thumbnail',
showSpinner: false
});
}
}
function prefetchNearby(images, index) {
const next = images[index + 1];
if (!next) return;
resolveDisplayImageUrlCached(next).catch(() => {});
}
function closePreview(el) {
const state = getState(el);
state.opened = false;
state.index = 0;
state.requestToken++;
removePreviewNodes(el);
}
function closeAllPreviews() {
knownLinks.forEach(closePreview);
document.querySelectorAll('.appendedHoverImgContainer').forEach(n => n.remove());
}
function removePreviewNodes(el) {
el.querySelectorAll('.appendedHoverImgContainer').forEach(n => n.remove());
}
function getTopicImagesCached(topicUrl) {
if (topicCache.has(topicUrl)) {
return topicCache.get(topicUrl);
}
const promise = fetchText(topicUrl)
.then(html => parseTopicImages(html, topicUrl))
.catch(err => {
topicCache.delete(topicUrl);
throw err;
});
topicCache.set(topicUrl, promise);
trimMap(topicCache, MAX_TOPIC_CACHE_SIZE);
return promise;
}
function parseTopicImages(html, topicUrl) {
const doc = new DOMParser().parseFromString(html, 'text/html');
// The attached script scopes to .post_body and reads var.postImg, which is cleaner
// than scanning the entire parsed HTML.
const root = doc.querySelector('.post_body') || doc;
const candidates = Array.from(root.querySelectorAll('var.postImg, .postImg'));
const images = [];
for (const node of candidates) {
const thumbUrlRaw = node.getAttribute('title') || node.title || '';
if (!thumbUrlRaw) continue;
const thumbUrl = normalizeUrl(thumbUrlRaw, topicUrl);
if (!thumbUrl) continue;
if (isBlockedSource(thumbUrl)) continue;
const parentAnchor = node.closest('a');
const parentUrl = parentAnchor
? normalizeUrl(parentAnchor.getAttribute('href') || parentAnchor.href, topicUrl)
: null;
// Keep your original row1 intent, but do not require it if we're already scoped
// to .post_body. Some pages may not preserve .row1 exactly when parsed.
const insideLikelyContent =
node.closest('.row1') ||
node.closest('.post_body') ||
root.classList?.contains('post_body');
if (!insideLikelyContent) continue;
images.push({
thumbUrl,
parentUrl
});
}
return dedupeImages(images);
}
function dedupeImages(images) {
const seen = new Set();
const result = [];
for (const item of images) {
const key = `${item.thumbUrl}|${item.parentUrl || ''}`;
if (seen.has(key)) continue;
seen.add(key);
result.push(item);
}
return result;
}
function isBlockedSource(url) {
return blockedSources.some(blocked => url.includes(blocked));
}
async function resolveDisplayImageUrlCached(item) {
const cacheKey = `${item.thumbUrl}|${item.parentUrl || ''}`;
if (resolvedImageCache.has(cacheKey)) {
return resolvedImageCache.get(cacheKey);
}
const promise = resolveDisplayImageUrl(item)
.catch(err => {
resolvedImageCache.delete(cacheKey);
throw err;
});
resolvedImageCache.set(cacheKey, promise);
return promise;
}
async function resolveDisplayImageUrl(item) {
let validSource = item.thumbUrl;
const parentUrl = item.parentUrl;
if (!validSource) return null;
// GIFs and most direct URLs can be shown directly. No base64 needed.
if (validSource.includes('.gif')) {
return validSource;
}
// Fast direct URL rewrites.
if (validSource.includes('imgbox') && validSource.includes('thumb')) {
return validSource
.replace('thumbs', 'images')
.replace('_t', '_o');
}
if (validSource.includes('imgdrive') && validSource.includes('small')) {
return validSource.replace('small', 'big');
}
if (validSource.includes('freescreens.ru') && validSource.includes('thumb')) {
return validSource
.replace('freescreens.', 'picforall.')
.replace('-thumb', '');
}
// Hosts that usually need the image view page parsed.
if (validSource.includes('fastpic.org') && parentUrl) {
return fetchFastpicBigImageObjectUrlCached(parentUrl);
}
if (validSource.includes('imgbox') && parentUrl) {
return fetchHostImageObjectUrlCached(parentUrl);
}
if (validSource.includes('imagevenue.com') && validSource.includes('thumbs') && parentUrl) {
return fetchHostImageObjectUrlCached(parentUrl);
}
if (validSource.includes('turboimg.net') && validSource.includes('/t/') && parentUrl) {
return fetchHostImageObjectUrlCached(parentUrl);
}
if (validSource.includes('picshick') && validSource.includes('/th/') && parentUrl) {
return fetchHostImageObjectUrlCached(parentUrl);
}
if (validSource.includes('picturelol') && validSource.includes('/th/') && parentUrl) {
return fetchHostImageObjectUrlCached(parentUrl);
}
// Normal direct image URL.
// Browser handles caching/decoding better than a userscript base64 cache.
return validSource;
}
function fetchFastpicBigImageObjectUrlCached(viewPageUrl) {
const key = `fastpic:${viewPageUrl}`;
if (resolvedImageCache.has(key)) {
return resolvedImageCache.get(key);
}
const promise = fetchFastpicBigImageObjectUrl(viewPageUrl)
.catch(err => {
resolvedImageCache.delete(key);
throw err;
});
resolvedImageCache.set(key, promise);
return promise;
}
function fetchHostImageObjectUrlCached(viewPageUrl) {
const key = `host:${viewPageUrl}`;
if (resolvedImageCache.has(key)) {
return resolvedImageCache.get(key);
}
const promise = fetchHostImageObjectUrl(viewPageUrl)
.catch(err => {
resolvedImageCache.delete(key);
throw err;
});
resolvedImageCache.set(key, promise);
return promise;
}
async function fetchFastpicBigImageObjectUrl(viewPageUrl) {
const response = await gmRequest({
method: 'GET',
url: viewPageUrl,
responseType: 'blob'
});
const contentType = getContentType(response);
if (contentType.includes('image/')) {
return URL.createObjectURL(response.response);
}
if (!contentType.includes('text/html')) {
return null;
}
const html = await blobToText(response.response);
const doc = new DOMParser().parseFromString(html, 'text/html');
const images = Array.from(doc.querySelectorAll('img'));
const validImage = images.find(img =>
img.src.includes('md5=') && img.src.includes('expires=')
);
if (!validImage) return null;
return fetchImageAsObjectUrl(validImage.src);
}
async function fetchHostImageObjectUrl(viewPageUrl) {
const response = await gmRequest({
method: 'GET',
url: viewPageUrl,
responseType: 'blob'
});
const contentType = getContentType(response);
if (contentType.includes('image/')) {
return URL.createObjectURL(response.response);
}
if (!contentType.includes('text/html')) {
return null;
}
const html = await blobToText(response.response);
const doc = new DOMParser().parseFromString(html, 'text/html');
const images = Array.from(doc.querySelectorAll('img'));
const validImage = images.find(img => {
const src = img.src || '';
return (
(src.includes('turboimg.net') && src.includes('/sp/')) ||
src.includes('cdn-images.imagevenue') ||
src.includes('picshick.com/i') ||
src.includes('picturelol.com/i') ||
src.includes('images2.imgbox.com') ||
src.includes('images.imgbox.com')
);
});
if (!validImage) return null;
return fetchImageAsObjectUrl(validImage.src);
}
async function fetchImageAsObjectUrl(url) {
const response = await gmRequest({
method: 'GET',
url,
responseType: 'blob'
});
const contentType = getContentType(response);
if (!contentType.includes('image/')) {
// Some hosts omit content-type. Still try if blob exists.
if (!response.response) return null;
}
return URL.createObjectURL(response.response);
}
function fetchText(url) {
return gmRequest({
method: 'GET',
url,
responseType: 'text',
headers: {
Referer: url,
'User-Agent': navigator.userAgent
}
}).then(response => response.responseText || response.response || '');
}
function gmRequest(options) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: options.method || 'GET',
url: options.url,
responseType: options.responseType,
headers: options.headers || undefined,
timeout: options.timeout || 30000,
onload: response => {
if (response.status >= 200 && response.status < 400) {
resolve(response);
} else {
reject(new Error(`HTTP ${response.status} for ${options.url}`));
}
},
ontimeout: () => reject(new Error(`Timeout for ${options.url}`)),
onerror: err => reject(err)
});
});
}
function getContentType(response) {
const headers = (response.responseHeaders || '').toLowerCase();
const line = headers
.split(/\r?\n/)
.find(header => header.startsWith('content-type:'));
return line || '';
}
function blobToText(blob) {
if (!blob) return Promise.resolve('');
// Blob.text() is cleaner, but FileReader is safer in older userscript contexts.
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onload = () => resolve(reader.result || '');
reader.onerror = reject;
reader.readAsText(blob);
});
}
function showPreview(el, options) {
const container = getPreviewContainer(el);
container.style.left = PREVIEW_LEFT;
container.style.marginTop = `${options.topMargin}px`;
container.style.height = `${options.height}px`;
const img = container.querySelector('.preview-img');
const label = container.querySelector('.imageCounterLabel');
const spinnerRow = container.querySelector('.preview-loading');
label.textContent = `Image ${options.index + 1}/${options.total}`;
if (options.showSpinner) {
spinnerRow.style.display = 'flex';
spinnerRow.querySelector('.spinner-text').textContent = options.loadingText || '';
} else {
spinnerRow.style.display = 'none';
}
if (img.src !== options.src) {
img.src = options.src;
}
}
function getPreviewContainer(el) {
let container = el.querySelector('.appendedHoverImgContainer');
if (container) return container;
container = document.createElement('div');
container.className = 'appendedHoverImgContainer';
container.style.cssText = `
position: absolute;
left: ${PREVIEW_LEFT};
margin-top: 15px;
height: ${DEFAULT_PREVIEW_HEIGHT}px;
width: auto;
z-index: 9999;
pointer-events: none;
`;
const img = document.createElement('img');
img.className = 'preview-img';
img.decoding = 'async';
img.loading = 'eager';
img.style.cssText = `
height: 100%;
width: auto;
display: block;
background: rgba(0, 0, 0, 0.25);
box-shadow: 0 4px 20px rgba(0,0,0,0.45);
`;
const label = document.createElement('div');
label.className = 'imageCounterLabel';
label.style.cssText = `
position: absolute;
top: 5px;
left: 5px;
color: #fff;
font-weight: normal;
font-family: verdana, sans-serif;
background-color: rgba(0, 0, 0, 0.6);
padding: 2px 6px;
font-size: 12px;
border-radius: 4px;
pointer-events: none;
`;
const loading = document.createElement('div');
loading.className = 'preview-loading';
loading.style.cssText = `
position: absolute;
left: 5px;
bottom: 5px;
display: flex;
align-items: center;
gap: 8px;
padding: 3px 6px;
border-radius: 4px;
background: rgba(0,0,0,0.6);
`;
const spinner = document.createElement('div');
spinner.className = 'spinner';
const spinnerText = document.createElement('span');
spinnerText.className = 'spinner-text';
loading.appendChild(spinner);
loading.appendChild(spinnerText);
container.appendChild(img);
container.appendChild(label);
container.appendChild(loading);
el.appendChild(container);
return container;
}
function showSpinner(el, host) {
const position = calculatePreviewPosition(el);
showPreview(el, {
src: transparentPixel(),
index: 0,
total: 1,
height: position.height,
topMargin: position.topMargin,
loadingText: host ? `Loading from ${host}...` : 'Loading...',
showSpinner: true
});
}
function showMessage(el, message) {
const position = calculatePreviewPosition(el);
const container = getPreviewContainer(el);
container.style.left = PREVIEW_LEFT;
container.style.marginTop = `${position.topMargin}px`;
container.style.height = '32px';
const img = container.querySelector('.preview-img');
const label = container.querySelector('.imageCounterLabel');
const spinnerRow = container.querySelector('.preview-loading');
img.src = transparentPixel();
label.textContent = message;
spinnerRow.style.display = 'none';
}
function calculatePreviewPosition(el) {
const y = el.getBoundingClientRect().top;
const windowHeight = window.innerHeight;
let height = DEFAULT_PREVIEW_HEIGHT;
let topMargin = 15;
if (windowHeight < y + 1800 - 15) {
height = windowHeight - y - 45;
if (height < MIN_PREVIEW_HEIGHT) {
height = MIN_PREVIEW_HEIGHT;
topMargin = windowHeight - y - 225;
}
}
return { height, topMargin };
}
function normalizeUrl(url, baseUrl) {
if (!url) return null;
try {
return new URL(url, baseUrl || location.href).href;
} catch {
return null;
}
}
function getHostLabel(thumbUrl, parentUrl) {
try {
if (parentUrl) return new URL(parentUrl).hostname;
return new URL(thumbUrl).hostname;
} catch {
return 'host';
}
}
function trimMap(map, maxSize) {
while (map.size > maxSize) {
const firstKey = map.keys().next().value;
map.delete(firstKey);
}
}
function injectStyles() {
const style = document.createElement('style');
style.textContent = `
.spinner {
border: 5px solid rgba(255,255,255,0.18);
border-left-color: #aaa;
border-radius: 50%;
width: 14px;
height: 14px;
animation: pornolabPreviewSpin 1s linear infinite;
flex: 0 0 auto;
}
.spinner-text {
font-size: 13px;
color: #eee;
font-weight: normal;
font-family: sans-serif;
white-space: nowrap;
}
@keyframes pornolabPreviewSpin {
to { transform: rotate(360deg); }
}
`;
document.head.appendChild(style);
}
function transparentPixel() {
return 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///ywAAAAAAQABAAACAUwAOw==';
}
function log(...args) {
if (DEBUG) {
console.log('[Pornolab preview]', ...args);
}
}
})();