Ragforge Gallery Swallower

列表页长滚动阅读模式:缩略图原地替换成高清图/视频/GIF

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na inštaláciu tohto skriptu budete musieť nainštalovať rozšírenie, ako je napríklad Tampermonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

// ==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('缓存已清空');
        });
    }

})();