Enable long-strip reader mode for any user-defined gallery website (with image source URL is predictable).
// ==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, '&').replace(/"/g, '"').replace(/'/g, ''').replace(/</g, '<').replace(/>/g, '>');
},
// 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();
})();