Sleazy Fork is available in English.
列表页长滚动阅读模式:缩略图原地替换成高清图/视频/GIF
// ==UserScript==
// @name Ragforge Gallery Swallower
// @namespace Ragfroge
// @description 列表页长滚动阅读模式:缩略图原地替换成高清图/视频/GIF
// @version 3.7
// @author YourName
// @match https://rule34.gg/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_registerMenuCommand
// @connect cdn.rule34.gg
// @connect rule34.gg
// @run-at document-end
// @license MIT
// ==/UserScript==
(function () {
'use strict';
// ================= 配置 =================
const CONFIG = {
batchSize: 30,
batchLoadMore: 20,
probeTimeout: 8000,
resourceHosts: ['https://cdn.rule34.gg', 'https://rule34.gg'],
referer: 'https://rule34.gg/',
rootMargin: '0px 0px 3000px 0px',
imageFirst: ['jpeg', 'png'],
imageFallback: ['jpg', 'gif'],
videoExt: 'mp4',
};
const probeCache = new Map();
let floatingBall = null;
let readerContainer = null;
let allItems = [];
let renderedCount = 0;
let observer = null;
// ================= 启动 =================
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
function init() {
addStyles();
createFloatingBall();
console.log('[x.x Gallery] loaded v3.7');
}
// ================= 样式 =================
function addStyles() {
GM_addStyle(`
#xxg-ball {
position: fixed; right: 16px; bottom: 90px;
width: 52px; height: 52px; border-radius: 50%;
background: #2980b9; color: #fff;
display: flex; align-items: center; justify-content: center;
font-size: 24px; z-index: 999999; cursor: pointer;
box-shadow: 0 4px 12px rgba(0,0,0,.4);
user-select: none; -webkit-tap-highlight-color: transparent;
}
#xxg-ball:active { transform: scale(.92); }
#xxg-reader {
position: fixed; inset: 0; background: #111;
z-index: 999998; overflow-y: auto;
-webkit-overflow-scrolling: touch;
}
#xxg-reader .xxg-topbar {
position: sticky; top: 0;
display: flex; justify-content: space-between; align-items: center;
padding: 10px 14px; background: rgba(0,0,0,.85);
backdrop-filter: blur(6px); z-index: 10;
color: #fff; font-size: 14px;
}
#xxg-reader .xxg-topbar button {
background: #c0392b; color: #fff; border: none;
padding: 8px 14px; border-radius: 6px; font-size: 14px; cursor: pointer;
}
#xxg-reader .xxg-list { padding: 10px; }
#xxg-reader .xxg-item {
margin: 12px 0; background: #1c1c1c;
border-radius: 10px; overflow: hidden;
position: relative; min-height: 60px;
}
#xxg-reader .xxg-item img,
#xxg-reader .xxg-item video {
display: block; width: 100%; height: auto;
max-height: 90vh; object-fit: contain; background: #000;
}
#xxg-reader .xxg-item .xxg-loading {
padding: 30px; text-align: center; color: #888; font-size: 14px;
}
#xxg-reader .xxg-item .xxg-error {
padding: 20px; text-align: center; color: #e74c3c;
font-size: 13px; word-break: break-all;
}
#xxg-reader .xxg-item .xxg-badge {
position: absolute; top: 8px; left: 8px;
background: rgba(0,0,0,.6); color: #fff;
font-size: 12px; padding: 3px 8px; border-radius: 4px;
pointer-events: none;
}
#xxg-reader .xxg-sentinel { height: 40px; }
#xxg-reader .xxg-end {
text-align: center; color: #666; padding: 20px; font-size: 13px;
}
`);
}
// ================= 悬浮球 =================
function createFloatingBall() {
if (floatingBall) return;
floatingBall = document.createElement('div');
floatingBall.id = 'xxg-ball';
floatingBall.textContent = '🖼️';
floatingBall.title = '阅读模式';
// ✅ 改为 toggle:已开→关,未开→开
floatingBall.addEventListener('click', toggleReader);
document.body.appendChild(floatingBall);
}
// ✅ 切换:打开 / 关闭
function toggleReader() {
if (readerContainer) {
closeReader();
} else {
openReader();
}
}
// ================= 收集列表页条目 =================
function collectItems() {
const items = [];
const seen = new Set();
const links = document.querySelectorAll('a[href*="/post?id="]');
links.forEach(a => {
const href = a.getAttribute('href') || '';
const m = href.match(/\/post\?id=(\d+)/);
if (!m) return;
const postId = m[1];
if (seen.has(postId)) return;
const img = a.querySelector('img');
if (!img) return;
const thumbSrc = img.src || img.getAttribute('data-src');
if (!thumbSrc || !thumbSrc.includes('/preview/')) return;
const idMatch = thumbSrc.match(/\/preview\/([^./]+)\.\w+/);
if (!idMatch) return;
const fileId = idMatch[1];
seen.add(postId);
items.push({
postId, fileId, thumbSrc,
alt: img.alt || ('Post ' + postId),
});
});
return items;
}
// ================= 打开阅读模式 =================
function openReader() {
if (readerContainer) return;
allItems = collectItems();
renderedCount = 0;
if (allItems.length === 0) {
alert('未找到图片');
return;
}
readerContainer = document.createElement('div');
readerContainer.id = 'xxg-reader';
const topbar = document.createElement('div');
topbar.className = 'xxg-topbar';
const title = document.createElement('div');
title.textContent = `阅读模式 · 共 ${allItems.length} 项`;
const closeBtn = document.createElement('button');
closeBtn.textContent = '✕ 关闭';
closeBtn.addEventListener('click', closeReader);
topbar.appendChild(title);
topbar.appendChild(closeBtn);
readerContainer.appendChild(topbar);
const list = document.createElement('div');
list.className = 'xxg-list';
list.id = 'xxg-list';
readerContainer.appendChild(list);
const sentinel = document.createElement('div');
sentinel.className = 'xxg-sentinel';
sentinel.id = 'xxg-sentinel';
readerContainer.appendChild(sentinel);
document.body.appendChild(readerContainer);
document.body.style.overflow = 'hidden';
renderBatch();
setupObserver();
document.addEventListener('keydown', escHandler);
}
function closeReader() {
if (observer) { observer.disconnect(); observer = null; }
if (readerContainer) {
readerContainer.remove();
readerContainer = null;
}
document.body.style.overflow = '';
document.removeEventListener('keydown', escHandler);
}
function escHandler(e) {
if (e.key === 'Escape') closeReader();
}
// ================= 分批渲染 =================
function renderBatch() {
const list = document.getElementById('xxg-list');
if (!list) return;
const start = renderedCount;
const end = Math.min(renderedCount + CONFIG.batchSize, allItems.length);
for (let i = start; i < end; i++) {
list.appendChild(createItemElement(allItems[i], i));
}
renderedCount = end;
if (renderedCount >= allItems.length) {
const endMark = document.createElement('div');
endMark.className = 'xxg-end';
endMark.textContent = '—— 到底了 ——';
endMark.id = 'xxg-end-mark';
const s = document.getElementById('xxg-sentinel');
if (s) s.replaceWith(endMark);
}
}
// ================= 单个条目元素 =================
function createItemElement(item, index) {
const wrap = document.createElement('div');
wrap.className = 'xxg-item';
wrap.dataset.index = index;
wrap.dataset.fileId = item.fileId;
const loading = document.createElement('div');
loading.className = 'xxg-loading';
loading.textContent = '等待加载...';
wrap.appendChild(loading);
wrap._item = item;
wrap._loadingEl = loading;
wrap._resolved = false;
return wrap;
}
// ================= IntersectionObserver =================
function setupObserver() {
observer = new IntersectionObserver((entries) => {
entries.forEach(entry => {
const el = entry.target;
if (entry.isIntersecting) {
if (!el._resolved) {
el._resolved = true;
resolveItem(el);
}
}
});
}, {
root: readerContainer,
rootMargin: CONFIG.rootMargin,
threshold: 0,
});
document.querySelectorAll('.xxg-item').forEach(el => observer.observe(el));
const sentinel = document.getElementById('xxg-sentinel');
if (sentinel) {
const sentinelObs = new IntersectionObserver((entries) => {
entries.forEach(entry => {
if (entry.isIntersecting) {
renderBatch();
document.querySelectorAll('.xxg-item:not([data-observed])').forEach(el => {
el.dataset.observed = '1';
observer.observe(el);
});
}
});
}, { root: readerContainer, rootMargin: '3000px 0px' });
sentinelObs.observe(sentinel);
}
}
// ================= 解析单个条目 =================
async function resolveItem(el) {
const item = el._item;
const loading = el._loadingEl;
const cached = probeCache.get(item.fileId);
if (cached) {
renderResolved(el, item, cached);
return;
}
loading.textContent = '加载中...';
try {
const result = await resolveResource(item.fileId);
probeCache.set(item.fileId, result);
renderResolved(el, item, result);
} catch (e) {
probeCache.set(item.fileId, { failed: true });
loading.remove();
const err = document.createElement('div');
err.className = 'xxg-error';
err.textContent = `加载失败:${item.fileId}`;
el.appendChild(err);
}
}
// ================= 核心:直接并行加载 =================
async function resolveResource(fileId) {
try {
return await resolveOnHost(CONFIG.resourceHosts[0], fileId);
} catch (e) {
if (CONFIG.resourceHosts[1]) {
return await resolveOnHost(CONFIG.resourceHosts[1], fileId);
}
throw e;
}
}
async function resolveOnHost(host, fileId) {
try {
return await raceImages(host, fileId, CONFIG.imageFirst);
} catch (e) { /* 继续 */ }
try {
return await raceImages(host, fileId, CONFIG.imageFallback);
} catch (e) { /* 继续 */ }
return await probeVideo(host, fileId);
}
function raceImages(host, fileId, exts) {
return new Promise((resolve, reject) => {
let settled = false;
let pending = exts.length;
exts.forEach(ext => {
const url = `${host}/${fileId}.${ext}`;
const img = new Image();
img.onload = () => {
if (settled) return;
settled = true;
resolve({
type: ext === 'gif' ? 'gif' : 'image',
url,
ext,
preloadedImg: img,
});
};
img.onerror = () => {
pending--;
if (pending === 0 && !settled) {
settled = true;
reject(new Error('all images miss'));
}
};
img.src = url;
});
});
}
function probeVideo(host, fileId) {
const url = `${host}/${fileId}.${CONFIG.videoExt}`;
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'HEAD',
url,
headers: { 'Referer': CONFIG.referer },
timeout: CONFIG.probeTimeout,
onload: (res) => {
if (res.status >= 200 && res.status < 400) {
resolve({ type: 'video', url, ext: CONFIG.videoExt });
} else {
reject(new Error('video miss'));
}
},
onerror: () => reject(new Error('video error')),
ontimeout: () => reject(new Error('video timeout')),
});
});
}
// ================= 渲染 =================
function renderResolved(el, item, result) {
const loading = el._loadingEl;
if (loading) loading.remove();
const oldErr = el.querySelector('.xxg-error');
if (oldErr) oldErr.remove();
if (result.failed) {
const err = document.createElement('div');
err.className = 'xxg-error';
err.textContent = `加载失败:${item.fileId}`;
el.appendChild(err);
return;
}
const badge = document.createElement('div');
badge.className = 'xxg-badge';
if (result.type === 'video') {
badge.textContent = '▶ 视频';
el.appendChild(badge);
const video = document.createElement('video');
video.src = result.url;
video.poster = item.thumbSrc;
video.controls = true;
video.muted = true;
video.loop = true;
video.playsInline = true;
video.preload = 'metadata';
video.setAttribute('webkit-playsinline', '');
video.setAttribute('x5-playsinline', '');
el.appendChild(video);
} else {
badge.textContent = result.type === 'gif' ? 'GIF' : '图片';
el.appendChild(badge);
let img;
if (result.preloadedImg) {
img = result.preloadedImg;
img.alt = item.alt;
img.loading = 'lazy';
img.decoding = 'async';
img.style.cssText = '';
} else {
img = document.createElement('img');
img.src = result.url;
img.alt = item.alt;
img.loading = 'lazy';
img.decoding = 'async';
}
el.appendChild(img);
}
}
// ================= 菜单 =================
if (typeof GM_registerMenuCommand === 'function') {
GM_registerMenuCommand('打开/关闭阅读模式', toggleReader);
GM_registerMenuCommand('清空探测缓存', () => {
probeCache.clear();
alert('缓存已清空');
});
}
})();