Gallery Long-Strip Reader

Enable long-strip reader mode for any user-defined gallery website (with image source URL is predictable).

이 스크립트를 설치하려면 Tampermonkey, Greasemonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램을 설치해야 합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Violentmonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

// ==UserScript==
// @name         Gallery Long-Strip Reader
// @namespace    http://tampermonkey.net/
// @version      8.7.4
// @description  Enable long-strip reader mode for any user-defined gallery website (with image source URL is predictable).
// @author       php
// @match        *://nhentai.net/*
// @match        *://imhentai.xxx/view/*
// @require      https://code.jquery.com/jquery-3.5.1.min.js
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_deleteValue
// @grant        GM_registerMenuCommand
// @license      MIT
// ==/UserScript==

(function() {
    'use strict';

    // Save references to original console logging methods before potential debugging override
    const _origLog = console.log;
    const _origWarn = console.warn;

    // =====================================================================
    // --- 1. CONFIGURATION & DATA STORAGE MODULE ---
    // Houses the master default template and local DB storage operations.
    // =====================================================================
    const CONFIG_MODULE = {
        STORAGE_KEY: "gallery_long-strip_reader_cfg",

        // Default layout for creating entirely new websites from scratch
        BLANK_PROFILE_TEMPLATE: {
            urlTemplate: "https://{cdn}.example.com/{gid}/{page}.{ext}",
            firstImgSelector: "",
            cdnList: "",
            allowedExtensions: "webp, jpg, png, gif",
            readerUrlRegex: "",
            pagesSelector: "",
            icSelector: "",
            imageFitMode: "smart",
            preloadCount: 3,
            throttleDelay: 200,
            imageGap: 15,
            loadTimeout: 5000,
            showFloatingIndicator: true,
            cdnHealth: 3,
            extHealth: 3,
            maxConcurrentLoads: 4,
            removeElements: ""
        },

        // Unified JSON-like default data object containing global settings and site profiles
        DEFAULT_DATA: {
            settings: {
                debugMode: false
            },
            profiles: {
                "nhentai": {
                    urlTemplate: "https://{cdn}.nhentai.net/galleries/{gid}/{page}.{ext}",
                    firstImgSelector: "#image-container img@src",
                    cdnList: "i1, i2, i3, i4",
                    allowedExtensions: "webp, jpg, png, gif",
                    readerUrlRegex: String.raw`https?:\/\/nhentai\.net\/g\/\d+\/\d+\/?`,
                    pagesSelector: "span.num-pages",
                    icSelector: "#image-container",
                    imageFitMode: "smart",
                    preloadCount: 3,
                    throttleDelay: 200,
                    imageGap: 15,
                    loadTimeout: 5000,
                    showFloatingIndicator: true,
                    cdnHealth: 3,
                    extHealth: 3,
                    maxConcurrentLoads: 4,
                    removeElements: ""
                },
                "imhentai": {
                    urlTemplate: "https://{cdn}.imhentai.xxx/{gid}/{page}.{ext}",
                    firstImgSelector: "#gimg@src",
                    cdnList: "m11, m10, m2",
                    allowedExtensions: "webp, jpg, png, gif",
                    readerUrlRegex: String.raw`https?:\/\/imhentai\.xxx\/view\/\d+\/\d+\/?`,
                    pagesSelector: "body > div.overlay > div > div.row.gallery_view > div > div:nth-child(1) > button > span.total_pages",
                    icSelector: "body > div.overlay > div > div.row.gallery_view > div > div.pre_img",
                    imageFitMode: "smart",
                    preloadCount: 3,
                    throttleDelay: 200,
                    imageGap: 15,
                    loadTimeout: 5000,
                    showFloatingIndicator: true,
                    cdnHealth: 3,
                    extHealth: 3,
                    maxConcurrentLoads: 4,
                    removeElements: "#bar, #footer, .nav_pagination.col-md-12:nth-child(2 of .nav_pagination.col-md-12), a.return_btn.btn.btn-primary"
                }
            }
        },

        getInitialStorage: function() {
            return JSON.parse(JSON.stringify(this.DEFAULT_DATA));
        }
    };

    // Load multi-profile setup from Tampermonkey local database
    let globalStorage = GM_getValue(CONFIG_MODULE.STORAGE_KEY);

    // Safety Fallbacks for migrating users from older versions
    if (!globalStorage || !globalStorage.profiles) {
        globalStorage = CONFIG_MODULE.getInitialStorage();
    }
    if (!globalStorage.settings) {
        globalStorage.settings = { debugMode: false };
    }

    // --- Dynamic Runtime Directives ---
    let activeProfileName = "";
    let CONFIG = null;
    let GLOBAL_SETTINGS = globalStorage.settings;

    let currentCdnList = [];
    let currentExtList = [];
    let healthRegistry = { cdn: 3, ext: 3 };
    let activeGid = "";
    let lastProcessedGid = "";

    // Apply global debug configuration
    if (GLOBAL_SETTINGS.debugMode) {
        console.log = _origLog;
        console.warn = _origWarn;
    } else {
        console.log = console.warn = () => {};
    }

    // =====================================================================
    // --- 2. HTML/CSS ASSET REPOSITORY (UI_ASSETS) ---
    // Centralized configuration-driven design. No inline styling in core logic.
    // =====================================================================
    const UI_ASSETS = {
        indicatorStyle: `position: fixed; bottom: 0vh; left: 50%; transform: translateX(-50%); color: rgba(224, 224, 224, 0.9); -webkit-text-stroke: 0.7px black; font-size: clamp(10px, 3.5vw, 20px); font-family: sans-serif; font-weight: 800; z-index: 9999; pointer-events: none;`,

        buildPlaceholderHtml: function(pageNum, gap) {
            const style = `min-height:80vh; width:100%; text-align:center; margin-top:${gap}px; background:#111; display:flex; align-items:center; justify-content:center; color:#aaa;`;
            return `<div id="p-con-${pageNum}" class="strip-page" data-page="${pageNum}" style="${style}">Loading Page ${pageNum}...</div>`;
        },

        buildFailureBoxHtml: function(pageNum, triedUrlsHtml) {
            const containerStyle = `padding: 20px; border: 1px solid #444; background: #1a1a1a; color: #ff4d4d; width: 80%; max-width: 600px; margin: 0 auto; display: flex; flex-direction: column; align-items: center;`;
            const btnStyle = `padding: 6px 16px; background: #6f42c1; color: white; border: none; border-radius: 4px; cursor: pointer; font-weight: bold; margin-bottom: 12px; font-size: 13px;`;
            const logBoxStyle = `max-height: 120px; overflow-y: auto; text-align: left; background: #111; border: 1px solid #333; padding: 8px; width: 100%; box-sizing: border-box;`;

            return `
                <div style="${containerStyle}">
                    <p style="font-weight:bold; margin: 0 0 10px 0;">❌ Page ${pageNum} Failed All Fallbacks</p>
                    <button class="refetch-btn" style="${btnStyle}">🔄 Refetch Page ${pageNum}</button>
                    <div style="${logBoxStyle}">${triedUrlsHtml}</div>
                </div>`;
        },

        escapeHtmlAttr: function(str) {
            if (!str) return '';
            return str.toString().replace(/&/g, '&amp;').replace(/"/g, '&quot;').replace(/'/g, '&#39;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
        },

        // Modals for settings UI
        modalCss: `<style>
            #scroll-overlay { position: fixed; top:0; left:0; width:100%; height:100%; background:rgba(0,0,0,0.3); backdrop-filter: blur(4px); -webkit-backdrop-filter: blur(4px); z-index:999999; display:flex; align-items:center; justify-content:center; padding: 10px; box-sizing: border-box; }
            #scroll-modal { background: rgba(255, 255, 255, 0.75); backdrop-filter: blur(20px) saturate(150%); -webkit-backdrop-filter: blur(20px) saturate(150%); border: 1px solid rgba(255, 255, 255, 0.4); padding:20px 25px; border-radius:12px; width:100%; max-width:700px; max-height: 95vh; overflow-y: auto; font-family: system-ui, sans-serif; box-shadow:0 10px 40px rgba(0,0,0,0.2); color:#222; }

            #scroll-modal h3 { margin:0 0 15px; font-size:20px; border-bottom:2px solid rgba(111, 66, 193, 0.5); padding-bottom:8px; display: flex; justify-content: center; align-items: center; font-weight: bold;}
            #scroll-modal label { display:block; margin:12px 0 4px; font-weight:700; font-size:11px; color:#444; text-transform:uppercase; letter-spacing: 0.5px; white-space: normal; word-wrap: break-word; overflow-wrap: break-word; line-height: 1.3; }

            #scroll-modal input, #scroll-modal select, #scroll-modal textarea { width:100%; padding:10px; border:1px solid rgba(0,0,0,0.15); border-radius:6px; box-sizing:border-box; font-size:13px; background: rgba(255,255,255,0.6); transition: border 0.2s, background 0.2s; font-family: monospace; }
            #scroll-modal input:focus, #scroll-modal select:focus, #scroll-modal textarea:focus { border-color: #6f42c1; outline: none; background: #fff; }

            /* Profile Outer Card Wrapper */
            .profile-wrapper-card { background: rgba(0, 0, 0, 0.03); border: 1px solid rgba(0, 0, 0, 0.08); border-radius: 12px; padding: 12px; margin-top: 15px; box-shadow: inset 0 2px 10px rgba(0,0,0,0.02); }

            /* Reusable Layout Cards */
            .section-container { background: rgba(0, 0, 0, 0.02); border: 1px solid rgba(0, 0, 0, 0.08); border-radius: 8px; padding: 10px; margin-bottom: 15px; }
            .section-title { margin: 0 0 15px 0; font-size: 15px; color: #333; text-align: center; border-bottom: 1px solid rgba(0,0,0,0.1); padding-bottom: 8px; }

            .s-section { margin-bottom: 15px; padding: 10px; border-radius: 8px; border: 1px solid rgba(233, 236, 239, 0.5); background: rgba(248, 249, 250, 0.5); }

            /* Responsive Flexbox */
            .s-flex { display: flex; flex-wrap: wrap; gap: 15px; }
            .s-flex > div { flex: 1 1 200px; box-sizing: border-box; min-width: 0; }
            .s-btns { display:flex; justify-content:flex-end; gap:10px; margin-top:20px; align-items: center; position: sticky; bottom: -20px; background: rgba(255,255,255,0.85); backdrop-filter: blur(10px); -webkit-backdrop-filter: blur(10px); padding: 15px 25px; margin: 20px -25px -20px -25px; border-top: 1px solid rgba(0,0,0,0.1); z-index: 10; border-radius: 0 0 12px 12px; }
            .s-btn { padding:10px 20px; border-radius:6px; cursor:pointer; border:none; font-weight:bold; font-size: 13px; transition: opacity 0.2s; }
            .s-btn:hover { opacity: 0.85; }
            .s-save { background:#6f42c1; color:#fff; }
            .s-reset { background:#dc3545; color:#fff; margin-right: auto; }
            .s-cancel { background: rgba(0,0,0,0.1); color:#333; }
            .s-secondary { background:#28a745; color:#fff; }

            /* Improved Hint Styling */
            p.s-hint { font-size:11px; color:#555; margin: 6px 0 0; line-height: 1.4; }
            code { background: rgba(0,0,0,0.06); padding: 2px 4px; border-radius: 4px; font-family: monospace; font-size: 11px; color: #d63384; font-weight: bold; }

            .checkbox-item { flex: 1 1 180px; display: flex; flex-direction: column; justify-content: center; min-width: 160px; }
            #scroll-modal input[type="checkbox"] { width: auto !important; margin: 0 8px 0 0 !important; cursor: pointer; padding: 0; background: none; transform: scale(1.1); }
            .checkbox-label { display: flex !important; align-items: center; justify-content: flex-start; text-transform: none !important; cursor: pointer; margin: 4px 0 !important; font-size: 13px !important; color: #222 !important; white-space: normal; line-height: 1.4; }
        </style>`,

        getModalHtml: function(storage, selectedProfileName) {
            let profileOptions = "";
            Object.keys(storage.profiles).forEach(pName => {
                const selected = pName === selectedProfileName ? "selected" : "";
                profileOptions += `<option value="${this.escapeHtmlAttr(pName)}" ${selected}>${this.escapeHtmlAttr(pName)}</option>`;
            });
            profileOptions += `<option value="__new__">+ Add New Profile...</option>`;

            return `
            ${this.modalCss}
            <div id="scroll-overlay">
                <div id="scroll-modal">
                    <h3>Configuration</h3>

                    <div class="s-section" style="background: rgba(40, 167, 69, 0.1); border-color: rgba(40, 167, 69, 0.3);">
                        <label style="color: #28a745; font-size: 12px;">Global Settings</label>
                        <div class="checkbox-item">
                             <label class="checkbox-label"><input type="checkbox" id="cfg-debug" ${storage.settings.debugMode ? 'checked' : ''}> Show Console Log</label>
                        </div>
                    </div>

                    <div class="profile-wrapper-card">
                        <div class="s-section" style="background: rgba(241, 240, 246, 0.6); border-color: rgba(111, 66, 193, 0.4);">
                            <label style="color: #6f42c1; font-size: 12px;">Active Profile (Website)</label>
                            <div class="s-flex" style="align-items: center;">
                                <div class="s-flex" style="align-items: center; flex: 1 1 70%;">
                                    <div style="flex: 2; display: flex; gap: 8px;">
                                        <select id="set-profile-selector" style="flex: 1;">
                                            ${profileOptions}
                                        </select>
                                        <button class="s-btn s-reset" id="btn-delete-profile" style="padding: 10px; margin: 0; display: ${selectedProfileName === '__new__' ? 'none' : 'block'};" title="Delete Active Profile">🗑️</button>
                                    </div>
                                    <div id="new-profile-name-container" style="flex: 2; display: none;">
                                        <input id="set-new-profile-name" type="text" placeholder="Profile name">
                                    </div>
                                </div>
                                <div style="display: flex; gap: 5px; justify-content: flex-end; width: 100%; flex: 1 1 70%;">
                                    <button class="s-btn s-secondary" id="btn-export" title="Export All Profiles to file" style="padding: 10px 12px; flex: 1;">📤 Export</button>
                                    <button class="s-btn s-secondary" id="btn-import" title="Import Profiles from file" style="padding: 10px 12px; background: #007bff; flex: 1;">📥 Import</button>
                                    <input type="file" id="import-file-input" style="display: none;" accept=".json">
                                </div>
                            </div>
                        </div>

                        <div id="profile-fields-container"></div>
                    </div>

                    <div class="s-btns">
                        <button class="s-btn s-reset" id="set-reset">Reset All Profiles</button>
                        <button class="s-btn s-cancel" id="set-cancel">Cancel</button>
                        <button class="s-btn s-save" id="set-save">Save & Reload</button>
                    </div>
                </div>
            </div>`;
        },

        getFieldsHtml: function(cfg) {
            return `
            <div class="section-container">
                <h4 class="section-title">🖼️ Image Fetching Configuration</h4>

                <div class="s-section">
                    <label style="text-align: center; font-size: 14px; font-weight: bold;">1. Site urls</label>
                    <div class="s-flex">
                        <div>
                            <label>Gallery Reader Page URL (Regex)</label>
                            <input id="set-regex" value="${this.escapeHtmlAttr(cfg.readerUrlRegex)}">
                        </div>
                    </div>
                    <div style="margin-top: 10px;">
                        <label>Image source URL Template</label>
                        <input id="set-template" value="${this.escapeHtmlAttr(cfg.urlTemplate)}">
                        <p class="s-hint">Require placeholders: <code>{cdn}</code>, <code>{gid}</code>, <code>{page}</code>, and <code>{ext}</code>.</p>
                        <p style="display:block; text-align:left; font-size:11px; margin-top:5px; padding-left: 5px; border-left: 2px solid #6f42c1; color: #555;">
                            <code>{cdn}</code>: CDN node | <code>{gid}</code>: Gallery ID | <code>{page}</code>: Page number | <code>{ext}</code>: Image extension
                        </p>
                    </div>
                </div>

                <div class="s-section">
                    <label style="text-align: center; font-size: 14px; font-weight: bold;">2. Initial Placeholders</label>
                    <div class="s-flex">
                        <div>
                            <label>First Page Image Source URL (CSS-Selector@Attr)</label>
                            <input id="set-first" value="${this.escapeHtmlAttr(cfg.firstImgSelector)}">
                            <p class="s-hint">Use <code>@ATTR</code> for HTML Attribute (e.g., <code>@src</code>). Omit <code>@ATTR</code> to use innerText.</p>
                        </div>
                    </div>
                    
                </div>

                <div class="s-section">
                    <label style="text-align: center; font-size: 14px; font-weight: bold;">3. Fallback Placeholders</label>
                    <div class="s-flex">
                        <div>
                            <label>CDN nodes (Comma separated)</label>
                            <input id="set-cdn-list" value="${this.escapeHtmlAttr(cfg.cdnList)}">
                        </div>
                        <div>
                            <label>Extensions (Comma separated)</label>
                            <input id="set-exts" value="${this.escapeHtmlAttr(cfg.allowedExtensions)}">
                        </div>
                    </div>
                    <p class="s-hint">Re-fetch of image after a failed request will use these placeholders prioritized from left to right.</p>
                </div>

                <div class="s-section">
                    <label style="text-align: center; font-size: 14px; font-weight: bold;">4. Page elements</label>
                    <div class="s-flex">
                        <div>
                            <label>Total Page Counts (CSS-Selector@Attr)</label>
                            <input id="set-pages" value="${this.escapeHtmlAttr(cfg.pagesSelector)}">
                             <p class="s-hint">Use <code>@ATTR</code> for HTML Attribute (e.g., <code>@src</code>). Omit <code>@ATTR</code> to use innerText.</p>
                        </div>
                        <div>
                            <label>Container for All Images(CSS-Selector)</label>
                            <input id="set-ic" value="${this.escapeHtmlAttr(cfg.icSelector)}">
                        </div>
                    </div>
                </div>

                <div class="s-section">
                    <label style="text-align: center; font-size: 14px; font-weight: bold;">5. (Advanced) Network request setting</label>
                    <div class="s-flex">
                        <div>
                            <label>Max Concurrency</label>
                            <input id="set-concurrent" type="number" value="${cfg.maxConcurrentLoads}">
                            <p class="s-hint">Max simultaneous network requests (slots). Should be less than browser limits (6 for most browsers).</p>
                        </div>
                    </div>
                    <div class="s-flex">
                        <div>
                            <label>Load Timeout (ms)</label>
                            <input id="set-timeout" type="number" value="${cfg.loadTimeout}">
                            <p class="s-hint">Max latency allowed before considering a fetch failed.</p>
                        </div>
                        <div>
                            <label>Throttle Delay (ms)</label>
                            <input id="set-delay" type="number" value="${cfg.throttleDelay}">
                            <p class="s-hint">Delay between network requests.</p>
                        </div>
                    </div>
                    <div class="s-flex">
                        <div>
                            <label>CDN Health (Strikes)</label>
                            <input id="set-cdn-health" type="number" value="${cfg.cdnHealth}">
                            <p class="s-hint">Failed fetches allowed before updating CDN nodes priority.</p>
                        </div>
                        <div>
                            <label>Extension Health (Strikes)</label>
                            <input id="set-ext-health" type="number" value="${cfg.extHealth}">
                            <p class="s-hint">Failed fetches allowed before updating file Extension priority.</p>
                        </div>
                    </div>
                </div>
            </div> <div class="section-container">
                <h4 class="section-title">📖 Reader Configuration</h4>

                <div class="s-section">
                    <label style="text-align: center; font-size: 14px; font-weight: bold;">UI setting</label>
                    <div class="s-flex" style="margin-bottom: 10px;">
                        <div>
                            <label>Image Fit Mode</label>
                            <select id="set-fit-mode">
                                <option value="width" ${cfg.imageFitMode === 'width' ? 'selected' : ''}>Fit Width</option>
                                <option value="height" ${cfg.imageFitMode === 'height' ? 'selected' : ''}>Fit Height</option>
                                <option value="smart" ${cfg.imageFitMode === 'smart' ? 'selected' : ''}>Smart Fit</option>
                            </select>
                        </div>
                        <div>
                            <label>Preload Page Count</label>
                            <input id="set-preload" type="number" value="${cfg.preloadCount}">
                        </div>
                        <div>
                            <label>Gap Size (px)</label>
                            <input id="set-gap" type="number" value="${cfg.imageGap}">
                        </div>
                    </div>

                    <div class="s-flex" style="margin-bottom: 10px;">
                        <div style="margin-top:5px; width:100%;">
                            <label>HTML Elements to Remove (CSS-Selector, Comma separated)</label>
                            <textarea id="set-remove">${this.escapeHtmlAttr(cfg.removeElements || "")}</textarea>
                            <p class="s-hint">Example: <code>#header, .ads-wrapper, .footer-links</code></p>
                        </div>
                    </div>
                    <div class="checkbox-item">
                         <label class="checkbox-label"><input type="checkbox" id="cfg-floating" ${cfg.showFloatingIndicator ? 'checked' : ''}> Show current page number</label>
                    </div>
                </div>
            </div> `;
        }
    };

    // =====================================================================
    // --- 3. SETTINGS UI MANAGER ---
    // Handles the rendering and saving of the Tampermonkey overlay menu.
    // =====================================================================
    function showSettings() {
        if (document.getElementById('scroll-overlay')) return;

        let workingStorage = JSON.parse(JSON.stringify(globalStorage));
        let currentEditingProfile = activeProfileName;

        const overlayDiv = document.createElement('div');
        overlayDiv.innerHTML = UI_ASSETS.getModalHtml(workingStorage, currentEditingProfile);
        document.body.appendChild(overlayDiv);

        const fieldsContainer = document.getElementById('profile-fields-container');
        const selector = document.getElementById('set-profile-selector');
        const newProfileNameContainer = document.getElementById('new-profile-name-container');
        const newProfileNameInput = document.getElementById('set-new-profile-name');

        function saveFieldsToWorkingCopy(profileName) {
            if (!profileName || profileName === '__new__') return;
            if (!workingStorage.profiles[profileName]) {
                workingStorage.profiles[profileName] = {};
            }

            // Save Global Settings
            workingStorage.settings.debugMode = document.getElementById('cfg-debug').checked;

            // Save Specific Profile Settings
            const prof = workingStorage.profiles[profileName];
            prof.urlTemplate = document.getElementById('set-template').value;
            prof.firstImgSelector = document.getElementById('set-first').value;
            prof.cdnList = document.getElementById('set-cdn-list').value;
            prof.allowedExtensions = document.getElementById('set-exts').value;
            prof.readerUrlRegex = document.getElementById('set-regex').value;
            prof.pagesSelector = document.getElementById('set-pages').value;
            prof.icSelector = document.getElementById('set-ic').value;
            prof.imageFitMode = document.getElementById('set-fit-mode').value;
            prof.preloadCount = parseInt(document.getElementById('set-preload').value) || 3;
            prof.imageGap = parseInt(document.getElementById('set-gap').value) || 15;
            prof.removeElements = document.getElementById('set-remove').value;
            prof.showFloatingIndicator = document.getElementById('cfg-floating').checked;
            prof.maxConcurrentLoads = parseInt(document.getElementById('set-concurrent').value) || 4;
            prof.loadTimeout = parseInt(document.getElementById('set-timeout').value) || 5000;
            prof.throttleDelay = parseInt(document.getElementById('set-delay').value) || 200;
            prof.cdnHealth = parseInt(document.getElementById('set-cdn-health').value) || 3;
            prof.extHealth = parseInt(document.getElementById('set-ext-health').value) || 3;
        }

        function renderFieldsForProfile(profileName) {
            let cfg = CONFIG_MODULE.BLANK_PROFILE_TEMPLATE;
            if (profileName !== '__new__' && workingStorage.profiles[profileName]) {
                cfg = workingStorage.profiles[profileName];
            }
            fieldsContainer.innerHTML = UI_ASSETS.getFieldsHtml(cfg);
        }

        renderFieldsForProfile(currentEditingProfile);

        selector.onchange = () => {
            const nextProfile = selector.value;
            document.getElementById('btn-delete-profile').style.display = (nextProfile === '__new__') ? 'none' : 'block';

            if (currentEditingProfile !== '__new__') {
                saveFieldsToWorkingCopy(currentEditingProfile);
            }

            if (nextProfile === '__new__') {
                newProfileNameContainer.style.display = 'block';
                newProfileNameInput.value = '';
                renderFieldsForProfile('__new__');
            } else {
                newProfileNameContainer.style.display = 'none';
                renderFieldsForProfile(nextProfile);
            }
            currentEditingProfile = nextProfile;
        };

        // Delete Profile Logic
        document.getElementById('btn-delete-profile').onclick = () => {
            if (currentEditingProfile === '__new__') return;
            const profilesArray = Object.keys(workingStorage.profiles);
            if (profilesArray.length <= 1) {
                alert("Cannot delete the last remaining profile.");
                return;
            }
            if (confirm(`Are you sure you want to delete the profile "${currentEditingProfile}"?`)) {
                delete workingStorage.profiles[currentEditingProfile];
                const updatedProfiles = Object.keys(workingStorage.profiles);
                currentEditingProfile = updatedProfiles[0];

                // Re-render dropdown
                let newOptions = "";
                updatedProfiles.forEach(pName => {
                    const selected = pName === currentEditingProfile ? "selected" : "";
                    newOptions += `<option value="${UI_ASSETS.escapeHtmlAttr(pName)}" ${selected}>${UI_ASSETS.escapeHtmlAttr(pName)}</option>`;
                });
                newOptions += `<option value="__new__">+ Add New Profile...</option>`;
                selector.innerHTML = newOptions;

                renderFieldsForProfile(currentEditingProfile);
            }
        };

        // File I/O Logic
        document.getElementById('btn-export').onclick = () => {
            if (currentEditingProfile !== '__new__') saveFieldsToWorkingCopy(currentEditingProfile);

            // Construct local YYYY-MM-DD_HH-mm-ss format
            const now = new Date();
            const y = now.getFullYear();
            const m = String(now.getMonth() + 1).padStart(2, '0');
            const d = String(now.getDate()).padStart(2, '0');
            const H = String(now.getHours()).padStart(2, '0');
            const M = String(now.getMinutes()).padStart(2, '0');
            const S = String(now.getSeconds()).padStart(2, '0');
            const formattedTime = `${y}-${m}-${d}_${H}-${M}-${S}`;

            // Reconstruct object to force _meta to the top and drop currentProfileName
            const exportData = {
                _meta: {
                    scriptVersion: typeof GM_info !== 'undefined' ? GM_info.script.version : "Unknown",
                    exportDate: formattedTime
                },
                settings: workingStorage.settings,
                profiles: workingStorage.profiles
            };

            const dataStr = "data:text/json;charset=utf-8," + encodeURIComponent(JSON.stringify(exportData, null, 4));
            const downloadAnchor = document.createElement('a');
            downloadAnchor.setAttribute("href", dataStr);
            downloadAnchor.setAttribute("download", `Gallery_Long-Strip_Reader_backup_${formattedTime}.json`);
            document.body.appendChild(downloadAnchor);
            downloadAnchor.click();
            downloadAnchor.remove();
        };

        const importInput = document.getElementById('import-file-input');
        document.getElementById('btn-import').onclick = () => importInput.click();
        importInput.onchange = (e) => {
            const file = e.target.files[0];
            if (!file) return;
            const reader = new FileReader();
            reader.onload = (event) => {
                try {
                    const imported = JSON.parse(event.target.result);
                    if (imported && imported.profiles && typeof imported.profiles === 'object') {
                        workingStorage = imported;
                        if(!workingStorage.settings) workingStorage.settings = { debugMode: false }; // safety check

                        let profileOptions = "";
                        Object.keys(workingStorage.profiles).forEach(pName => {
                            profileOptions += `<option value="${UI_ASSETS.escapeHtmlAttr(pName)}">${UI_ASSETS.escapeHtmlAttr(pName)}</option>`;
                        });
                        profileOptions += `<option value="__new__">+ Add New Profile...</option>`;
                        selector.innerHTML = profileOptions;

                        const profiles = Object.keys(workingStorage.profiles);
                        currentEditingProfile = profiles[0]; // Simply grab the first profile
                        selector.value = currentEditingProfile;

                        newProfileNameContainer.style.display = 'none';
                        document.getElementById('cfg-debug').checked = workingStorage.settings.debugMode;
                        renderFieldsForProfile(currentEditingProfile);
                        alert("Environment maps synchronized! Click 'Save & Reload' to apply structures permanently.");
                    } else {
                        alert("Invalid deployment profile document configuration schema.");
                    }
                } catch (err) {
                    alert("Parse processing fault tracking context file: " + err.message);
                }
            };
            reader.readAsText(file);
        };

        document.getElementById('set-cancel').onclick = () => overlayDiv.remove();

        document.getElementById('set-reset').onclick = () => {
            if (confirm("Reset local architecture allocations? This removes custom profiles.")) {
                GM_deleteValue(CONFIG_MODULE.STORAGE_KEY);
                location.reload();
            }
        };

        document.getElementById('set-save').onclick = () => {
            let targetProfileName = currentEditingProfile;

            if (currentEditingProfile === '__new__') {
                const newName = newProfileNameInput.value.trim();
                if (!newName || newName === '__new__') {
                    alert("Please identify a clean unique identification string profile label name.");
                    return;
                }
                targetProfileName = newName;
                workingStorage.profiles[targetProfileName] = JSON.parse(JSON.stringify(CONFIG_MODULE.BLANK_PROFILE_TEMPLATE));
            }

            saveFieldsToWorkingCopy(targetProfileName);

            GM_setValue(CONFIG_MODULE.STORAGE_KEY, workingStorage);
            location.reload();
        };
    }

    GM_registerMenuCommand("Configuration", showSettings);

    // =====================================================================
    // --- 4. INDEPENDENT FEATURE MODULE ---
    // Contains standalone UI overlays and UX quality of life tools.
    // =====================================================================
    const FEATURE_MODULE = {
        /**
         * Parses 'removeElements' config string and uses jQuery to hide unwanted UI bloat.
         */
        cleanupUI: function() {
            if (!CONFIG || !CONFIG.removeElements) return;
            const selectors = CONFIG.removeElements.split(',').map(s => s.trim()).filter(s => s);
            selectors.forEach(selector => {
                $(selector).hide();
            });
        },

        /**
         * Creates a fixed UI element that updates dynamically based on scroll position.
         */
        setupFloatingPageIndicator: function(currentPage, totalPages) {
            if (!CONFIG.showFloatingIndicator) return;

            $('#floating-page-indicator').remove();

            // Tag the original container and new pages for tracking
            const ic = $(CONFIG.icSelector);
            ic.attr('data-page', currentPage).addClass('tracked-page');
            $('.strip-page').addClass('tracked-page');

            const indicator = document.createElement("div");
            indicator.id = "floating-page-indicator";
            indicator.style.cssText = UI_ASSETS.indicatorStyle;
            indicator.innerText = `${currentPage} / ${totalPages}`;
            document.body.appendChild(indicator);

            // Create observer (the 'tripwire' in the exact center of the screen)
            const observer = new IntersectionObserver((entries) => {
                entries.forEach(entry => {
                    if (entry.isIntersecting) {
                        const pNum = entry.target.getAttribute('data-page');
                        if (pNum) indicator.innerText = `${pNum} / ${totalPages}`;
                    }
                });
            }, { rootMargin: '-50% 0px -50% 0px' });

            document.querySelectorAll('.tracked-page').forEach(div => observer.observe(div));
        }
    };

    // =====================================================================
    // --- 5. NETWORK & CONSENSUS ENGINE ---
    // Manages the async dispatch queues and consensus rotation strategy.
    // =====================================================================
    const NETWORK_ENGINE = {
        requestQueue: [],
        activeLoadCount: 0,
        isProcessingQueue: false,

        /**
         * Modifies CDN and Extension priorities based on success or failure events.
         */
        updatePriorityWithConsensus: function(array, successValue, type, currentUrl = "", isFailure = false, isBlueprint = false) {
            if (!successValue) return;
            const val = successValue.trim();
            const index = array.indexOf(val);
            const maxHealth = type === 'cdn' ? CONFIG.cdnHealth : CONFIG.extHealth;

            // Blueprint Phase - Force override the tracker to index 0 based on extracted manifest
            if (isBlueprint) {
                if (index > 0) {
                    array.splice(index, 1);
                    array.unshift(val);
                    console.log(`[Blueprint] '${val}' forced to front for ${type.toUpperCase()}.`);
                } else if (index === -1) {
                    array.unshift(val);
                }
                healthRegistry[type] = maxHealth;
                return;
            }

            // Failure Phase - Strike a health point from the leader
            if (isFailure) {
                healthRegistry[type] = Math.max(0, healthRegistry[type] - 1);
                console.warn(`[Consensus] ${type.toUpperCase()} head strike! Health: ${healthRegistry[type]}/${maxHealth} | Value: ${val} | Failed URL: ${currentUrl}`);
                return;
            }

            // Success Phase - Heal leader or promote fallback
            if (index === 0) {
                const oldHealth = healthRegistry[type];
                healthRegistry[type] = Math.min(maxHealth, healthRegistry[type] + 1);
                if (healthRegistry[type] > oldHealth) {
                    console.log(`[Consensus] ${type.toUpperCase()} head heal! Health: ${healthRegistry[type]}/${maxHealth} | Value: ${val} | Success URL: ${currentUrl}`);
                }
                return;
            }

            if (healthRegistry[type] <= 0) {
                if (index > 0) {
                    array.splice(index, 1);
                    array.unshift(val);
                    console.log(`[${type.toUpperCase()} Rotation] '${val}' moved to front. Triggered by success URL: ${currentUrl}`);
                } else if (index === -1) {
                    array.unshift(val);
                    console.log(`[Consensus] New ${type.toUpperCase()} discovered and added: '${val}'`);
                }
                console.log(`[${type.toUpperCase()} Updated List Order]:`, array);
                healthRegistry[type] = maxHealth;
            }
        },

        loadImage: function(i, container) {
            this.requestQueue.push({ i, container });
            if (!this.isProcessingQueue) this.processQueue();
        },

        processQueue: async function() {
            if (this.isProcessingQueue) return;
            this.isProcessingQueue = true;

            while (this.requestQueue.length > 0) {
                // Concurrency limiting based on browser hardware caps
                if (this.activeLoadCount >= CONFIG.maxConcurrentLoads) {
                    await new Promise(r => setTimeout(r, 100));
                    continue;
                }

                const { i, container } = this.requestQueue.shift();
                this.activeLoadCount++;

                this.executeLoad(i, container);
                await new Promise(r => setTimeout(r, CONFIG.throttleDelay));
            }
            this.isProcessingQueue = false;
        },

        executeLoad: function(i, container) {
            const self = this; // Maintain context inside recursive closure

            const tryMatrix = (cdnIdx, extIdx) => {
                // 1. Matrix Exhaustion (All CDNs Failed)
                if (cdnIdx >= currentCdnList.length) {
                    let triedUrlsHtml = "";
                    currentCdnList.forEach(cdn => {
                        currentExtList.forEach(ext => {
                            const url = CONFIG.urlTemplate.replace('{page}', i).replace('{cdn}', cdn).replace('{ext}', ext).replace('{gid}', activeGid);
                            triedUrlsHtml += `<div style="font-size:10px; word-break:break-all; margin-top:5px; color:#888;">${UI_ASSETS.escapeHtmlAttr(url)}</div>`;
                        });
                    });

                    container.innerHTML = UI_ASSETS.buildFailureBoxHtml(i, triedUrlsHtml);
                    container.querySelector('.refetch-btn').addEventListener('click', () => {
                        container.innerHTML = `<div style="color:#444;">Refetching Page ${i}...</div>`;
                        self.loadImage(i, container);
                    });

                    self.activeLoadCount--;
                    return;
                }

                // 2. Extension Exhaustion (Strike CDN Leader, loop next)
                if (extIdx >= currentExtList.length) {
                    if (cdnIdx === 0) {
                        const failUrl = CONFIG.urlTemplate.replace('{page}', i).replace('{cdn}', currentCdnList[0]).replace('{ext}', 'ALL').replace('{gid}', activeGid);
                        self.updatePriorityWithConsensus(currentCdnList, currentCdnList[cdnIdx], 'cdn', failUrl, true);
                    }
                    tryMatrix(cdnIdx + 1, 0);
                    return;
                }

                const img = new Image();
                let isFinished = false;

                const targetUrl = CONFIG.urlTemplate
                    .replace('{page}', i)
                    .replace('{cdn}', currentCdnList[cdnIdx])
                    .replace('{ext}', currentExtList[extIdx])
                    .replace('{gid}', activeGid);

                // Setup Timeout watchdog
                const timer = setTimeout(() => {
                    if (!isFinished) {
                        isFinished = true;
                        img.src = "";
                        if (cdnIdx === 0) self.updatePriorityWithConsensus(currentCdnList, currentCdnList[cdnIdx], 'cdn', targetUrl, true);
                        tryMatrix(cdnIdx + 1, 0);
                    }
                }, CONFIG.loadTimeout);

                // Success Handler
                img.onload = () => {
                    if (isFinished) return;
                    isFinished = true;
                    clearTimeout(timer);
                    self.activeLoadCount--;
                    container.style.minHeight = '0'; // Collapse placeholder height

                    self.updatePriorityWithConsensus(currentCdnList, currentCdnList[cdnIdx], 'cdn', targetUrl);
                    self.updatePriorityWithConsensus(currentExtList, currentExtList[extIdx], 'ext', targetUrl);

                    container.innerHTML = '';
                    container.appendChild(img);
                };

                // Error Handler
                img.onerror = () => {
                    if (isFinished) return;
                    isFinished = true;
                    clearTimeout(timer);

                    if (extIdx === 0) {
                        self.updatePriorityWithConsensus(currentExtList, currentExtList[extIdx], 'ext', targetUrl, true);
                    }
                    tryMatrix(cdnIdx, extIdx + 1);
                };

                img.src = targetUrl;
            };

            tryMatrix(0, 0);
        }
    };


    // =====================================================================
    // --- 6. CORE WATCHDOG & SPA ROUTER ---
    // Controls the script injection loop and React/Vue SPA lifecycle handling.
    // =====================================================================
    let isWaitingForData = false;

    function initLongStrip() {
        const pCfg = CONFIG.pagesSelector;
        const lastAtP = pCfg.lastIndexOf('@');
        const pSelector = lastAtP !== -1 ? pCfg.substring(0, lastAtP) : pCfg;
        const pagesEl = $(pSelector).first();
        const totalPages = parseInt((lastAtP !== -1 ? (pagesEl.attr(pCfg.substring(lastAtP + 1)) || "") : pagesEl.text()).replace(/\D/g, ''));

        const ic = $(CONFIG.icSelector);
        if (ic.length === 0 || isNaN(totalPages)) { isWaitingForData = true; return; }

        if ($("#scroller-initialized").length > 0) return;
        isWaitingForData = false;
        ic.append('<div id="scroller-initialized" style="display:none;"></div>');

        // Apply Global Image Fit Mode Styling (Targets ALL child images in the container)
        $('#scroll-fit-mode-style').remove();
        const fitMode = CONFIG.imageFitMode || 'width';
        let fitCss = "";
        if (fitMode === 'width') {
            fitCss = "width: 100% !important; height: auto !important; max-width: 100vw !important;";
        } else if (fitMode === 'height') {
            fitCss = "height: 100vh !important; width: auto !important; max-width: 100vw !important; object-fit: contain !important;";
        } else if (fitMode === 'smart') {
            fitCss = "max-width: 100vw !important; max-height: 100vh !important; width: auto !important; height: auto !important; object-fit: contain !important;";
        }
        $('<style id="scroll-fit-mode-style">').text(`${CONFIG.icSelector} img { ${fitCss} display: block; margin: 0 auto; }`).appendTo('head');

        // Blueprint Extraction Phase
        const iCfg = CONFIG.firstImgSelector;
        const lastAtI = iCfg.lastIndexOf('@');
        const fSelector = lastAtI !== -1 ? iCfg.substring(0, lastAtI) : iCfg;
        const fEl = $(fSelector).first();
        const firstSrc = lastAtI !== -1 ? fEl.attr(iCfg.substring(lastAtI + 1)) : fEl.text();

        let currentPage = 1;

        if (firstSrc && CONFIG.urlTemplate) {
            try {
                // Dynamically build the extraction regex from the URL Template
                const escapedTemplate = CONFIG.urlTemplate.replace(/[-\/\\^$*+?.()|[\]{}]/g, '\\$&');
                const regexString = escapedTemplate
                    .replace(/^https?:\\\/\\\//i, 'https?:\\/\\/') // Allow flexible http:// or https:// matching
                    .replace(/\\{cdn\\}/g, '(?<cdn>.+)')
                    .replace(/\\{gid\\}/g, '(?<gid>.+)')
                    .replace(/\\{page\\}/g, '(?<page>\\d+)')
                    .replace(/\\{ext\\}/g, '(?<ext>\\w+)');

                const match = firstSrc.match(new RegExp(regexString));
                if (match && match.groups) {
                    const { cdn, gid, page: extractedPage, ext } = match.groups;
                    currentPage = parseInt(extractedPage) || 1;

                    if (gid && gid !== lastProcessedGid) {
                        activeGid = gid;
                        lastProcessedGid = gid;

                        if (cdn) NETWORK_ENGINE.updatePriorityWithConsensus(currentCdnList, cdn, 'cdn', "", false, true);
                        if (ext) NETWORK_ENGINE.updatePriorityWithConsensus(currentExtList, ext, 'ext', "", false, true);

                        console.log(`[Blueprint] Current CDN Order:`, currentCdnList);
                        console.log(`[Blueprint] Current EXT Order:`, currentExtList);
                    }
                }
            } catch (e) { console.error("Regex Matching Fault Instance", e); }
        }

        const queuedPages = new Set();

        // Inject UI Placeholders
        for (let i = currentPage + 1; i <= totalPages; i++) {
            ic.append(UI_ASSETS.buildPlaceholderHtml(i, CONFIG.imageGap));
        }

        // Initialize Native Lazy Loader
        const observer = new IntersectionObserver((entries) => {
            entries.forEach(entry => {
                if (entry.isIntersecting) {
                    const pNum = parseInt(entry.target.getAttribute('data-page'));
                    for (let j = pNum; j <= Math.min(pNum + CONFIG.preloadCount, totalPages); j++) {
                        if (j > 0 && !queuedPages.has(j)) {
                            queuedPages.add(j);
                            NETWORK_ENGINE.loadImage(j, document.getElementById(`p-con-${j}`));
                        }
                    }
                }
            });
        }, { rootMargin: '1000px' });

        document.querySelectorAll('.strip-page').forEach(div => observer.observe(div));
        FEATURE_MODULE.setupFloatingPageIndicator(currentPage, totalPages);
    }

    /**
     * Identifies proper profile via URL Regex. Avoids wiping arrays on localized DOM changes.
     */
    function selectProfileForUrl(url) {
        for (const [name, prof] of Object.entries(globalStorage.profiles)) {
            try {
                if (prof.readerUrlRegex && new RegExp(prof.readerUrlRegex).test(url)) {
                    if (activeProfileName !== name) {
                        activeProfileName = name;
                        CONFIG = prof;
                        currentCdnList = CONFIG.cdnList.split(',').map(c => c.trim());
                        currentExtList = CONFIG.allowedExtensions.split(',').map(e => e.trim().replace(/^\./, ''));
                        healthRegistry = { cdn: CONFIG.cdnHealth, ext: CONFIG.extHealth };
                    }
                    return true;
                }
            } catch (e) { } // Ignore corrupt regex maps
        }

        // Fallback default assignments
        if (!CONFIG) {
            activeProfileName = Object.keys(globalStorage.profiles)[0];
            CONFIG = globalStorage.profiles[activeProfileName];
        }
        return false;
    }

    // SPA Native History Monkey-patching
    const patchHistory = (type) => {
        const orig = history[type];
        return function() {
            const rv = orig.apply(this, arguments);
            window.dispatchEvent(new Event(type.toLowerCase()));
            return rv;
        };
    };
    history.pushState = patchHistory('pushState');
    history.replaceState = patchHistory('replaceState');

    let lastUrl = location.href;

    // Core Validation loop hook
    function triggerCheck() {
        const currentUrl = location.href;
        const isMatched = selectProfileForUrl(currentUrl);

        // Safety checkpoint for routing changes MUST happen before early exit
        if (currentUrl !== lastUrl) {
            lastUrl = currentUrl;
            $("#scroller-initialized").remove();
            $("#floating-page-indicator").remove();

            // Explicit reset of network trackers for entirely new galleries
            if (CONFIG) {
                currentCdnList = CONFIG.cdnList.split(',').map(c => c.trim());
                currentExtList = CONFIG.allowedExtensions.split(',').map(e => e.trim().replace(/^\./, ''));
                healthRegistry = { cdn: CONFIG.cdnHealth, ext: CONFIG.extHealth };
                lastProcessedGid = "";
            }
        }

        if (!isMatched) return;

        FEATURE_MODULE.cleanupUI();

        if ($("#scroller-initialized").length && !isWaitingForData) return;

        initLongStrip();
    }

    // Attach listeners
    window.addEventListener('pushstate', triggerCheck);
    window.addEventListener('replacestate', triggerCheck);
    window.addEventListener('popstate', triggerCheck);

    const spaObserver = new MutationObserver(triggerCheck);
    spaObserver.observe(document.body, { childList: true, subtree: true });

    // Initial deployment pass execution loop kick-off
    triggerCheck();
})();