Skip to content

Instantly share code, notes, and snippets.

@POMXARK
Created April 24, 2026 16:56
Show Gist options
  • Select an option

  • Save POMXARK/87e8cb73567de873fff868cd8a98d15e to your computer and use it in GitHub Desktop.

Select an option

Save POMXARK/87e8cb73567de873fff868cd8a98d15e to your computer and use it in GitHub Desktop.
// ==UserScript==
// @name Rutracker full size images embed
// @namespace Violentmonkey Scripts
// @match https://rutracker.org/forum/viewtopic.php*
// @grant GM_xmlhttpRequest
// @connect *
// @version 3.1
// @author szq2
// @run-at document-idle
// @license MIT
// @description Rutracker full size images embed fastpic.org vfl.ru
// ==/UserScript==
(async function () {
'use strict';
const style = document.createElement('style');
style.textContent = `
/* ── Inline replaced images ── */
.fp-post-img {
max-width: 100%; height: auto; display: block;
margin: 8px 0; cursor: zoom-in; width: 100%;
box-shadow: 0 0 0 1px #c3cbd1, 0 2px 6px rgba(0,0,0,0.12);
}
/* ── Shimmer placeholder while signed URL is fetching ── */
.fp-post-img.fp-loading {
min-height: 220px; width: 100%;
background: linear-gradient(90deg, #1e2a38 25%, #2a3d52 50%, #1e2a38 75%);
background-size: 400% 100%;
animation: fp-shimmer 1.4s ease infinite;
border-radius: 4px; cursor: default;
}
@keyframes fp-shimmer {
0% { background-position: 100% 0; }
100% { background-position: -100% 0; }
}
/* ── View mode toolbar (inside spoiler body) ── */
#fp-view-toolbar {
display: flex; align-items: center; gap: 8px;
padding: 6px 0 10px; font-family: sans-serif;
}
.fp-view-btn {
background: #2a4a6b; color: #fff;
border: 1px solid #4a7ab5; border-radius: 4px;
padding: 5px 13px; cursor: pointer; font-size: 13px;
transition: background 0.2s;
}
.fp-view-btn:hover { background: #3a6a9b; }
.fp-view-btn.active { background: #4a8ac5; border-color: #6aaaf5; }
/* ── Images wrapper ── */
#fp-images-wrapper .fp-post-img { width: 100%; height: auto; margin: 6px 0; }
#fp-images-wrapper.fp-grid-mode {
display: grid; grid-template-columns: repeat(3, 1fr); gap: 8px;
}
#fp-images-wrapper.fp-grid-mode .fp-post-img { margin: 0; }
/* ── Lightbox: full-screen overlay, children are absolutely positioned ── */
#fp-lightbox {
position: fixed; inset: 0; z-index: 99999;
background: rgba(0,0,0,0.92);
opacity: 0; pointer-events: none;
transition: opacity 0.22s ease;
}
#fp-lightbox.open { opacity: 1; pointer-events: all; }
/* ── Auto-hide UI when mouse idle ── */
.fp-tb, .fp-side-btn { transition: opacity 0.35s ease, background 0.2s; }
#fp-lightbox.fp-ui-hidden .fp-tb,
#fp-lightbox.fp-ui-hidden .fp-side-btn { opacity: 0; pointer-events: none; }
#fp-lightbox.fp-ui-hidden,
#fp-lightbox.fp-ui-hidden * { cursor: none !important; }
/* ── Floating toolbars — centered horizontally, pinned top/bottom ── */
.fp-tb {
position: absolute; left: 50%; transform: translateX(-50%);
z-index: 20;
display: flex; align-items: center; gap: 8px;
padding: 8px 16px; background: rgba(0,0,0,0.65);
border-radius: 8px; font-family: sans-serif;
white-space: nowrap;
}
.fp-tb-top { top: 12px; }
.fp-tb-bot { bottom: 12px; }
.fp-tb button {
background: rgba(255,255,255,0.15); border: none; color: #fff;
width: 36px; height: 36px; border-radius: 6px; font-size: 1rem;
cursor: pointer; transition: background 0.2s;
display: flex; align-items: center; justify-content: center;
}
.fp-tb button:hover { background: rgba(255,255,255,0.35); }
.fp-tb button.active { background: rgba(255,200,0,0.4); }
.fp-counter {
color: rgba(255,255,255,0.8); font-size: 14px;
min-width: 50px; text-align: center;
}
.fp-speed-sel {
background: rgba(255,255,255,0.1); border: 1px solid rgba(255,255,255,0.2);
color: #fff; border-radius: 6px; padding: 4px 6px; font-size: 13px;
cursor: pointer; height: 36px;
}
.fp-speed-sel option { background: #222; }
/* ── Image area fills the entire overlay ── */
#fp-img-wrap {
position: absolute; inset: 0;
overflow: hidden;
}
#fp-img-wrap.zoomed { overflow: auto; cursor: grab; }
#fp-img-wrap.dragging { cursor: grabbing !important; }
/* Container is sized by the spacer (in-flow); images float on top */
#fp-img-container {
position: relative;
min-width: 100%; min-height: 100%;
display: flex; align-items: center; justify-content: center;
}
#fp-zoom-spacer {
width: 100vw; height: 100vh;
display: block; flex-shrink: 0; pointer-events: none;
}
#fp-img-a, #fp-img-b {
position: absolute; top: 50%; left: 50%;
transform: translate(-50%, -50%);
max-width: 100vw; max-height: 100vh;
object-fit: contain; user-select: none; pointer-events: none;
transition: opacity 0.5s ease;
box-shadow: 0 0 40px rgba(0,0,0,0.6);
}
/* ── Zoom level badge / inline input ── */
.fp-zoom-val {
color: rgba(255,255,255,0.75); font-size: 13px;
min-width: 44px; text-align: center; cursor: text;
padding: 0 2px; line-height: 36px;
}
.fp-zoom-val:hover { color: #fff; }
.fp-zoom-input {
width: 58px; height: 28px; background: rgba(255,255,255,0.15);
border: 1px solid rgba(100,180,255,0.7); color: #fff;
border-radius: 4px; padding: 0 6px; font-size: 13px;
text-align: center; box-sizing: border-box;
-moz-appearance: textfield; outline: none;
}
.fp-zoom-input::-webkit-outer-spin-button,
.fp-zoom-input::-webkit-inner-spin-button { -webkit-appearance: none; margin: 0; }
/* ── Side arrows — absolute in lightbox, float over image ── */
.fp-side-btn {
position: absolute; top: 50%; transform: translateY(-50%);
background: rgba(0,0,0,0.45); border: none; color: #fff;
font-size: 2rem; width: 50px; height: 80px; cursor: pointer;
border-radius: 4px; transition: background 0.2s; user-select: none;
z-index: 10;
}
.fp-side-btn:hover { background: rgba(255,255,255,0.35); }
#fp-prev-side { left: 8px; }
#fp-next-side { right: 8px; }
`;
document.head.appendChild(style);
/* ── Lightbox DOM: toolbars float over full-screen image area ── */
const lightbox = document.createElement('div');
lightbox.id = 'fp-lightbox';
lightbox.innerHTML = `
<div class="fp-tb fp-tb-top" id="fp-tb-top">
<button id="fp-close-top" title="Закрыть (Esc)">&#10005;</button>
<button id="fp-prev-top" title="Назад (←)">&#8592;</button>
<span id="fp-counter-top" class="fp-counter">1 / 1</span>
<button id="fp-next-top" title="Вперёд (→)">&#8594;</button>
<button id="fp-play-top" title="Слайдшоу (Space)">&#9654;</button>
<select id="fp-speed-top" class="fp-speed-sel" title="Скорость">
<option value="2000">2с</option>
<option value="4000" selected>4с</option>
<option value="6000">6с</option>
<option value="10000">10с</option>
</select>
<button id="fp-zout-top" title="Уменьшить (-)">&#x2212;</button>
<span id="fp-zval-top" class="fp-zoom-val" title="Клик — ввести масштаб; 0 — сбросить">100%</span>
<button id="fp-zin-top" title="Увеличить (+)">+</button>
<button id="fp-fs-top" title="На весь экран (F)">&#x26F6;</button>
</div>
<div id="fp-img-wrap">
<div id="fp-img-container">
<div id="fp-zoom-spacer"></div>
<img id="fp-img-a" src="" alt="">
<img id="fp-img-b" src="" alt="" style="opacity:0">
</div>
</div>
<button class="fp-side-btn" id="fp-prev-side">&#8592;</button>
<button class="fp-side-btn" id="fp-next-side">&#8594;</button>
<div class="fp-tb fp-tb-bot" id="fp-tb-bot">
<button id="fp-close-bot" title="Закрыть (Esc)">&#10005;</button>
<button id="fp-prev-bot" title="Назад (←)">&#8592;</button>
<span id="fp-counter-bot" class="fp-counter">1 / 1</span>
<button id="fp-next-bot" title="Вперёд (→)">&#8594;</button>
<button id="fp-play-bot" title="Слайдшоу (Space)">&#9654;</button>
<select id="fp-speed-bot" class="fp-speed-sel" title="Скорость">
<option value="2000">2с</option>
<option value="4000" selected>4с</option>
<option value="6000">6с</option>
<option value="10000">10с</option>
</select>
<button id="fp-zout-bot" title="Уменьшить (-)">&#x2212;</button>
<span id="fp-zval-bot" class="fp-zoom-val" title="Клик — ввести масштаб; 0 — сбросить">100%</span>
<button id="fp-zin-bot" title="Увеличить (+)">+</button>
<button id="fp-fs-bot" title="На весь экран (F)">&#x26F6;</button>
</div>
`;
document.body.appendChild(lightbox);
let images = [];
let current = 0;
let slideshowTimer = null;
let active = 'a';
/* Drag-to-pan state */
let isDragging = false, didDrag = false;
let dragStartX = 0, dragStartY = 0, scrollStartX = 0, scrollStartY = 0;
/* ── Auto-hide UI after 2 s of mouse inactivity ── */
let uiHideTimer = null;
const showUI = () => {
lightbox.classList.remove('fp-ui-hidden');
clearTimeout(uiHideTimer);
uiHideTimer = setTimeout(() => lightbox.classList.add('fp-ui-hidden'), 2000);
};
lightbox.addEventListener('mousemove', showUI);
lightbox.addEventListener('mouseenter', showUI);
const getEl = (id) => document.getElementById(`fp-img-${id}`);
const getSpeed = () => parseInt(document.getElementById('fp-speed-top').value);
/* Repositions side arrows just outside the actual rendered image edges */
const positionArrows = () => {
const imgEl = getEl(active);
if (!imgEl || !imgEl.naturalWidth) return;
const ir = imgEl.getBoundingClientRect();
const arrowW = 50, gap = 6;
const prevBtn = document.getElementById('fp-prev-side');
const nextBtn = document.getElementById('fp-next-side');
if (prevBtn) {
prevBtn.style.left = Math.max(4, ir.left - arrowW - gap) + 'px';
prevBtn.style.right = 'auto';
}
if (nextBtn) {
nextBtn.style.right = Math.max(4, window.innerWidth - ir.right - arrowW - gap) + 'px';
nextBtn.style.left = 'auto';
}
};
const onResize = () => { applyZoom(); positionArrows(); };
/* Keep both toolbars in sync */
const updateUI = () => {
const txt = `${current + 1} / ${images.length}`;
const isPlaying = !!slideshowTimer;
['top', 'bot'].forEach(s => {
const counter = document.getElementById(`fp-counter-${s}`);
if (counter) counter.textContent = txt;
const btn = document.getElementById(`fp-play-${s}`);
if (btn) {
btn.innerHTML = isPlaying ? '&#9646;&#9646;' : '&#9654;';
btn.classList.toggle('active', isPlaying);
}
});
};
const open = (idx) => {
current = (idx + images.length) % images.length;
updateUI();
const isOpen = lightbox.classList.contains('open');
const nextSlot = active === 'a' ? 'b' : 'a';
const nextEl = getEl(nextSlot);
const activeEl = getEl(active);
if (!isOpen) {
activeEl.src = images[current];
activeEl.style.opacity = '1';
nextEl.style.opacity = '0';
requestAnimationFrame(() => lightbox.classList.add('open'));
showUI();
const onLoaded = () => { applyZoom(); positionArrows(); activeEl.onload = null; };
if (activeEl.complete && activeEl.naturalWidth) onLoaded();
else activeEl.onload = onLoaded;
window.addEventListener('resize', onResize);
return;
}
/* Center scroll when navigating while zoomed */
const wrap = document.getElementById('fp-img-wrap');
if (wrap && zoomLevel > 1) {
wrap.scrollLeft = (wrap.scrollWidth - wrap.clientWidth) / 2;
wrap.scrollTop = (wrap.scrollHeight - wrap.clientHeight) / 2;
}
nextEl.style.opacity = '0';
nextEl.src = images[current];
const doFade = () => {
nextEl.style.opacity = '1';
activeEl.style.opacity = '0';
active = nextSlot;
nextEl.onload = null;
positionArrows();
};
if (nextEl.complete && nextEl.naturalWidth) doFade();
else nextEl.onload = doFade;
};
const close = () => {
stopSlideshow();
clearTimeout(uiHideTimer);
lightbox.classList.remove('fp-ui-hidden');
window.removeEventListener('resize', onResize);
if (document.fullscreenElement) {
document.exitFullscreen?.() || document.webkitExitFullscreen?.();
}
lightbox.classList.remove('open');
setTimeout(() => {
if (lightbox.classList.contains('open')) return;
active = 'a';
getEl('a').style.opacity = '1';
getEl('b').style.opacity = '0';
}, 230);
};
const prev = () => { stopSlideshow(); open(current - 1); };
const next = () => { stopSlideshow(); open(current + 1); };
const startSlideshow = () => {
slideshowTimer = setInterval(() => open((current + 1) % images.length), getSpeed());
updateUI();
};
const stopSlideshow = () => {
if (!slideshowTimer) return;
clearInterval(slideshowTimer);
slideshowTimer = null;
updateUI();
};
const toggleSlideshow = () => slideshowTimer ? stopSlideshow() : startSlideshow();
/* Fullscreen toggle using Fullscreen API */
const toggleFullscreen = () => {
if (!document.fullscreenElement) {
lightbox.requestFullscreen?.() || lightbox.webkitRequestFullscreen?.();
} else {
document.exitFullscreen?.() || document.webkitExitFullscreen?.();
}
};
const updateFsBtn = () => {
const isFs = !!document.fullscreenElement;
['top', 'bot'].forEach(s => {
const btn = document.getElementById(`fp-fs-${s}`);
if (btn) { btn.innerHTML = isFs ? '&#x2922;' : '&#x26F6;'; btn.classList.toggle('active', isFs); }
});
};
document.addEventListener('fullscreenchange', updateFsBtn);
/* ── Zoom ── */
const ZOOM_STEPS = [0.1, 0.25, 0.33, 0.5, 0.67, 0.75, 0.9, 1.0, 1.1, 1.25, 1.5, 1.75, 2.0, 2.5, 3.0, 4.0, 5.0];
let zoomLevel = parseFloat(localStorage.getItem('fp-zoom') || '1');
const applyZoom = () => {
localStorage.setItem('fp-zoom', zoomLevel);
const label = Math.round(zoomLevel * 100) + '%';
['top', 'bot'].forEach(s => {
const v = document.getElementById(`fp-zval-${s}`);
if (v) v.textContent = label;
});
const imgEl = getEl(active);
const wrap = document.getElementById('fp-img-wrap');
const spacer = document.getElementById('fp-zoom-spacer');
if (zoomLevel === 1) {
[getEl('a'), getEl('b')].forEach(img => {
if (!img) return;
img.style.width = ''; img.style.height = '';
img.style.maxWidth = '100vw'; img.style.maxHeight = '100vh';
img.style.transform = 'translate(-50%, -50%)';
});
if (spacer) { spacer.style.width = '100vw'; spacer.style.height = '100vh'; }
if (wrap) { wrap.classList.remove('zoomed'); wrap.scrollLeft = 0; wrap.scrollTop = 0; }
return;
}
/* Compute the fitted base size (how image renders at zoom 1.0) */
const maxW = window.innerWidth;
const maxH = window.innerHeight;
let baseW = maxW, baseH = maxH;
if (imgEl && imgEl.naturalWidth && imgEl.naturalHeight) {
const r = imgEl.naturalWidth / imgEl.naturalHeight;
if (maxW / r <= maxH) { baseW = maxW; baseH = maxW / r; }
else { baseH = maxH; baseW = maxH * r; }
}
const scaledW = baseW * zoomLevel;
const scaledH = baseH * zoomLevel;
[getEl('a'), getEl('b')].forEach(img => {
if (!img) return;
img.style.maxWidth = 'none'; img.style.maxHeight = 'none';
img.style.width = scaledW + 'px';
img.style.height = scaledH + 'px';
img.style.transform = 'translate(-50%, -50%)';
});
if (spacer) { spacer.style.width = scaledW + 'px'; spacer.style.height = scaledH + 'px'; }
if (wrap) {
wrap.classList.add('zoomed');
requestAnimationFrame(() => {
wrap.scrollLeft = (wrap.scrollWidth - wrap.clientWidth) / 2;
wrap.scrollTop = (wrap.scrollHeight - wrap.clientHeight) / 2;
});
}
};
const zoomIn = () => { const i = ZOOM_STEPS.findIndex(z => z > zoomLevel); if (i !== -1) { zoomLevel = ZOOM_STEPS[i]; applyZoom(); positionArrows(); } };
const zoomOut = () => { const arr = [...ZOOM_STEPS].reverse(); const i = arr.findIndex(z => z < zoomLevel); if (i !== -1) { zoomLevel = arr[i]; applyZoom(); positionArrows(); } };
const zoomReset = () => { zoomLevel = 1; applyZoom(); positionArrows(); };
/* Click the zoom badge → inline number input; Enter/blur to apply, Esc to cancel */
const editZoom = () => {
if (document.getElementById('fp-zinput-top')) return;
const badges = { top: document.getElementById('fp-zval-top'), bot: document.getElementById('fp-zval-bot') };
let settled = false;
const commit = () => {
if (settled) return;
settled = true;
const inp = document.getElementById('fp-zinput-top');
if (inp) {
const v = parseFloat(inp.value);
if (!isNaN(v) && v >= 1 && v <= 2000) { zoomLevel = v / 100; applyZoom(); positionArrows(); }
}
restore();
};
const restore = () => {
['top', 'bot'].forEach(s => {
const inp = document.getElementById(`fp-zinput-${s}`);
if (inp && badges[s]) inp.replaceWith(badges[s]);
});
};
['top', 'bot'].forEach((s, i) => {
if (!badges[s]) return;
const input = document.createElement('input');
input.type = 'number'; input.min = 1; input.max = 2000;
input.className = 'fp-zoom-input'; input.id = `fp-zinput-${s}`;
input.value = Math.round(zoomLevel * 100);
input.onkeydown = (e) => {
e.stopPropagation();
if (e.key === 'Enter') commit();
if (e.key === 'Escape') { settled = true; restore(); }
};
input.onblur = () => setTimeout(commit, 120);
badges[s].replaceWith(input);
if (i === 0) requestAnimationFrame(() => { input.select(); input.focus(); });
});
};
const syncSpeed = (from, to) => {
document.getElementById(`fp-speed-${to}`).value =
document.getElementById(`fp-speed-${from}`).value;
};
/* Top toolbar */
document.getElementById('fp-close-top').onclick = close;
document.getElementById('fp-prev-top').onclick = prev;
document.getElementById('fp-next-top').onclick = next;
document.getElementById('fp-play-top').onclick = toggleSlideshow;
document.getElementById('fp-zout-top').onclick = zoomOut;
document.getElementById('fp-zval-top').onclick = editZoom;
document.getElementById('fp-zin-top').onclick = zoomIn;
document.getElementById('fp-fs-top').onclick = toggleFullscreen;
document.getElementById('fp-speed-top').onchange = () => {
syncSpeed('top', 'bot');
if (slideshowTimer) { stopSlideshow(); startSlideshow(); }
};
/* Bottom toolbar */
document.getElementById('fp-close-bot').onclick = close;
document.getElementById('fp-prev-bot').onclick = prev;
document.getElementById('fp-next-bot').onclick = next;
document.getElementById('fp-play-bot').onclick = toggleSlideshow;
document.getElementById('fp-zout-bot').onclick = zoomOut;
document.getElementById('fp-zval-bot').onclick = editZoom;
document.getElementById('fp-zin-bot').onclick = zoomIn;
document.getElementById('fp-fs-bot').onclick = toggleFullscreen;
document.getElementById('fp-speed-bot').onchange = () => {
syncSpeed('bot', 'top');
if (slideshowTimer) { stopSlideshow(); startSlideshow(); }
};
/* Side arrows */
document.getElementById('fp-prev-side').onclick = (e) => { e.stopPropagation(); prev(); };
document.getElementById('fp-next-side').onclick = (e) => { e.stopPropagation(); next(); };
/* ── Drag-to-pan ── */
const imgWrap = document.getElementById('fp-img-wrap');
imgWrap.addEventListener('mousedown', (e) => {
if (e.button !== 0) return;
isDragging = true;
didDrag = false;
dragStartX = e.clientX;
dragStartY = e.clientY;
scrollStartX = imgWrap.scrollLeft;
scrollStartY = imgWrap.scrollTop;
imgWrap.classList.add('dragging');
e.preventDefault();
});
document.addEventListener('mousemove', (e) => {
if (!isDragging) return;
const dx = e.clientX - dragStartX;
const dy = e.clientY - dragStartY;
if (Math.abs(dx) > 3 || Math.abs(dy) > 3) didDrag = true;
imgWrap.scrollLeft = scrollStartX - dx;
imgWrap.scrollTop = scrollStartY - dy;
});
document.addEventListener('mouseup', () => {
if (!isDragging) return;
isDragging = false;
imgWrap.classList.remove('dragging');
});
/* Container click: on image → navigate L/R half, outside image → close */
const container = document.getElementById('fp-img-container');
container.onclick = (e) => {
if (didDrag) { didDrag = false; return; }
const imgEl = getEl(active);
if (imgEl) {
const ir = imgEl.getBoundingClientRect();
/* Clamp to viewport so partial-scroll doesn't give wrong zone */
const vl = Math.max(ir.left, 0), vr = Math.min(ir.right, window.innerWidth);
const vt = Math.max(ir.top, 0), vb = Math.min(ir.bottom, window.innerHeight);
if (e.clientX >= vl && e.clientX <= vr && e.clientY >= vt && e.clientY <= vb) {
if (e.clientX < vl + (vr - vl) / 2) prev(); else next();
return;
}
}
close();
};
container.onmousemove = (e) => {
if (isDragging) return;
const imgEl = getEl(active);
if (!imgEl) { container.style.cursor = 'default'; return; }
const ir = imgEl.getBoundingClientRect();
const vl = Math.max(ir.left, 0), vr = Math.min(ir.right, window.innerWidth);
const vt = Math.max(ir.top, 0), vb = Math.min(ir.bottom, window.innerHeight);
if (e.clientX >= vl && e.clientX <= vr && e.clientY >= vt && e.clientY <= vb) {
container.style.cursor = e.clientX < vl + (vr - vl) / 2 ? 'w-resize' : 'e-resize';
} else {
container.style.cursor = 'default';
}
};
/* Keyboard shortcuts */
document.addEventListener('keydown', (e) => {
if (!lightbox.classList.contains('open')) return;
if (e.key === 'Escape') close();
if (e.key === 'ArrowLeft') prev();
if (e.key === 'ArrowRight') next();
if (e.key === ' ') { e.preventDefault(); toggleSlideshow(); }
if (e.key === 'f' || e.key === 'F') toggleFullscreen();
if (e.key === '+' || e.key === '=') zoomIn();
if (e.key === '-' || e.key === '_') zoomOut();
if (e.key === '0') zoomReset();
});
/* ── View mode (screenshot spoiler) ─────────────────────────────────────── */
let viewMode = localStorage.getItem('fp-view-mode') || 'full';
const applyViewMode = (mode) => {
viewMode = mode;
localStorage.setItem('fp-view-mode', mode);
const w = document.getElementById('fp-images-wrapper');
if (w) w.classList.toggle('fp-grid-mode', mode === 'grid');
document.querySelectorAll('.fp-view-btn').forEach(b => {
b.classList.toggle('active', b.dataset.mode === mode);
});
};
const findScreenshotBody = () => {
for (const h of document.querySelectorAll('.sp-head')) {
if (/скриншот/i.test(h.textContent)) {
const b = h.nextElementSibling;
if (b && b.classList.contains('sp-body')) return b;
}
}
return null;
};
const setupViewToolbar = (spoilerBody) => {
const toolbar = document.createElement('div');
toolbar.id = 'fp-view-toolbar';
const btnFull = document.createElement('button');
btnFull.className = 'fp-view-btn'; btnFull.dataset.mode = 'full';
btnFull.textContent = '▬ Полная ширина';
btnFull.onclick = () => applyViewMode('full');
const btnGrid = document.createElement('button');
btnGrid.className = 'fp-view-btn'; btnGrid.dataset.mode = 'grid';
btnGrid.textContent = '⊞ Сетка 3×';
btnGrid.onclick = () => applyViewMode('grid');
toolbar.append(btnFull, btnGrid);
spoilerBody.insertBefore(toolbar, spoilerBody.firstChild);
};
const wrapImages = (spoilerBody) => {
let wrapper = document.getElementById('fp-images-wrapper');
if (!wrapper) {
wrapper = document.createElement('div');
wrapper.id = 'fp-images-wrapper';
const anchor = spoilerBody.querySelector('#fp-view-toolbar')?.nextSibling ?? spoilerBody.firstChild;
spoilerBody.insertBefore(wrapper, anchor);
}
spoilerBody.querySelectorAll('.fp-post-img').forEach(img => {
if (img.parentElement !== wrapper) wrapper.appendChild(img);
});
applyViewMode(viewMode);
};
/* ── URL helpers ──────────────────────────────────────────────────────────── */
const getDirectImageUrl = (link) => {
if (link.includes('out.php?url=')) {
const match = link.match(/[?&]url=([^&]+)/);
if (match) return decodeURIComponent(match[1]);
}
if (/\.(jpg|jpeg|png|gif|webp)(\?.*)?$/i.test(link)) return link;
return null;
};
const getSignedUrl = (bigUrl) => {
const match = bigUrl.match(/https?:\/\/i(\d+)\.fastpic\.org\/big\/(\d{4}\/\d{4})\/[^/]+\/(.+)/);
if (!match) return Promise.resolve(null);
const viewUrl = `https://fastpic.org/view/${match[1]}/${match[2]}/${match[3]}.html`;
return new Promise((resolve) => {
GM_xmlhttpRequest({
method: 'GET',
url: viewUrl,
headers: { 'User-Agent': navigator.userAgent },
onload: (r) => {
if (r.status !== 200) { resolve(null); return; }
const m = r.responseText.match(/src="(https?:\/\/i\d+\.fastpic\.org\/big\/[^"]*\?[^"]*md5=[^"]+)"/);
resolve(m ? m[1].replace(/&amp;/g, '&') : null);
},
onerror: () => resolve(null),
ontimeout: () => resolve(null),
});
});
};
/* ── Processing: two-phase (DOM first, then fetch) ──────────────────────── */
const CONCURRENCY = 3;
const prepareLink = (a) => {
let link = a.href.replace('http://', 'https://');
if (!link.startsWith('https://')) return null;
let directUrl = getDirectImageUrl(link);
/* VFL support: full-size URL derived from thumbnail _s → no suffix */
if (!directUrl) {
const postImg = a.querySelector('.postImg');
const thumbSrc = (postImg?.getAttribute('src') || postImg?.getAttribute('title') || '')
.replace('http://', 'https://');
if (/images\.vfl\.ru\/ii\//i.test(thumbSrc)) {
directUrl = thumbSrc.replace(/_s(\.(jpe?g|png|gif|webp))$/i, '$1');
}
}
if (!directUrl) return null;
a.dataset.replaced = '1';
const idx = images.length;
images.push(null);
const img = document.createElement('img');
img.className = 'fp-post-img fp-loading';
a.parentNode.replaceChild(img, a);
return { img, directUrl, idx };
};
const fetchForSlot = async ({ img, directUrl, idx }) => {
/* Fastpic needs a signed URL fetched from the view page; all other hosts are direct */
let finalUrl;
if (/fastpic\.org\/big\//i.test(directUrl)) {
finalUrl = await getSignedUrl(directUrl);
} else {
finalUrl = directUrl;
}
if (!finalUrl) { img.classList.remove('fp-loading'); images.splice(idx, 1); return; }
images[idx] = finalUrl;
img.src = finalUrl;
img.onload = () => img.classList.remove('fp-loading');
img.onerror = () => { img.classList.remove('fp-loading'); images.splice(idx, 1); };
};
const reindexOnclick = () => {
images = images.filter(Boolean);
document.querySelectorAll('.fp-post-img').forEach((img, i) => {
img.onclick = () => open(i);
});
};
const processLinks = async (links) => {
if (!links.length) return;
const tasks = links.map(prepareLink).filter(Boolean);
if (spoilerBody) wrapImages(spoilerBody);
const queue = [...tasks];
const worker = async () => {
while (queue.length > 0) await fetchForSlot(queue.shift());
};
await Promise.all(Array.from({ length: Math.min(CONCURRENCY, tasks.length) }, worker));
reindexOnclick();
};
const findNewLinks = () =>
Array.from(document.querySelectorAll("a.postLink:has(.postImg):not([data-replaced])"));
/* ── Init ──────────────────────────────────────────────────────────────────── */
const spoilerBody = findScreenshotBody();
if (spoilerBody) setupViewToolbar(spoilerBody);
await processLinks(findNewLinks());
let pending = false;
const observer = new MutationObserver(async () => {
if (pending) return;
const newLinks = findNewLinks();
if (!newLinks.length) return;
pending = true;
await processLinks(newLinks);
pending = false;
});
observer.observe(document.body, { childList: true, subtree: true });
})();
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment