Ragforge Gallery Swallower

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

スクリプトをインストールするには、Tampermonkey, GreasemonkeyViolentmonkey のような拡張機能のインストールが必要です。

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

スクリプトをインストールするには、TampermonkeyViolentmonkey のような拡張機能のインストールが必要です。

スクリプトをインストールするには、TampermonkeyUserscripts のような拡張機能のインストールが必要です。

このスクリプトをインストールするには、Tampermonkeyなどの拡張機能をインストールする必要があります。

このスクリプトをインストールするには、ユーザースクリプト管理ツールの拡張機能をインストールする必要があります。

(ユーザースクリプト管理ツールは設定済みなのでインストール!)

このスタイルをインストールするには、Stylusなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus などの拡張機能をインストールする必要があります。

このスタイルをインストールするには、Stylus tなどの拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

このスタイルをインストールするには、ユーザースタイル管理用の拡張機能をインストールする必要があります。

(ユーザースタイル管理ツールは設定済みなのでインストール!)

このスクリプトの質問や評価の投稿はこちら通報はこちらへお寄せください
// ==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('缓存已清空');
        });
    }

})();