Sleazy Fork is available in English.
Automatically navigate next/previous pages for gallery sites by vertical scrolling, with scroll buffer for turning pages. Forked from [Gallery Scroll Navigator] by [Yukiteru]. User can now apply this script to any custom websites.
// ==UserScript==
// @name Gallery Scroll Navigator (custom setting)
// @namespace https://sleazyfork.org/
// @version 2.0
// @description Automatically navigate next/previous pages for gallery sites by vertical scrolling, with scroll buffer for turning pages. Forked from [Gallery Scroll Navigator] by [Yukiteru]. User can now apply this script to any custom websites.
// @author php
// @match https://hitomi.la/*
// @match https://www.pixiv.net/*
// @match https://nhentai.net/*
// @match https://exhentai.org/*
// @match https://imhentai.xxx/*
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_deleteValue
// @grant GM_registerMenuCommand
// @run-at document-end
// @license MIT
// ==/UserScript==
(function() {
'use strict';
/* ==========================================================================
0. Config 模組:集中管理所有可自訂參數、設定檔與 UI 樣式
========================================================================== */
const Config = {
DefaultSites: [
{ name: "Hitomi", host: "hitomi.la", code: "try { const pageContainer = [...document.querySelectorAll('.page-container li')]; const currentPage = pageContainer.filter(i => i.textContent !== '...').find(i => i.children.length === 0); const targetLi = direction === 'next' ? currentPage.nextElementSibling : currentPage.previousElementSibling; return targetLi ? targetLi.querySelector('a') : null; } catch(e) { return null; }" },
{ name: "Pixiv", host: "www.pixiv.net", code: "const selector = `nav:has(button) > a:${direction === 'next' ? 'last' : 'first'}-child`; const pageButton = document.querySelector(selector); return pageButton && !pageButton.hasAttribute('hidden') ? pageButton : null;" },
{ name: "Nhentai", host: "nhentai.net", code: "const selector = direction === 'next' ? '.next' : '.previous'; const pageButton = document.querySelector(selector); return pageButton || null;" },
{ name: "Exhentai", host: "exhentai.org", code: "const selector = direction === 'next' ? 'a#dnext' : 'a#dprev'; const pageButton = document.querySelector(selector); return pageButton || null;" },
{ name: "Imhentai", host: "imhentai.xxx", code: "const selector = direction === 'next' ? 'ul.pagination > li.page-item:last-child > a' : 'ul.pagination > li.page-item:first-child > a'; const pageButton = document.querySelector(selector); return pageButton && !pageButton.href.includes('#') ? pageButton : null;" }
],
DefaultSettings: {
mouseScrolls: 8,
touchSensitivity: 5,
disableMobileRefresh: true,
disablePrev: false,
accumulateMode: true // 預設開啟累加法
},
System: {
progressBarColor: 'red',
progressBarHeight: '3px',
progressBarZIndex: '2147483647',
touchDistanceBaseline: 600,
touchDistanceStep: 50,
bottomTolerance: 5,
observerSelectors: ['nav', '.pagination', '.pager', '.page-nav', '#pagination', 'footer'],
safetyIntervalMs: 5000,
menuName: "Configuration",
wheelResetDelay: 300
},
UI: {
CSS: `
/* 動態類別:只有當使用者勾選停用重新整理時,才會套用此類別到 html/body 上 */
html.gsn-no-refresh,
body.gsn-no-refresh {
overscroll-behavior-y: contain !important;
}
/* --- 進度條核心樣式 --- */
.gsn-progress-line {
position: fixed;
left: 0; right: 0;
pointer-events: none;
transform-origin: center;
transform: scaleX(0);
transition: transform 0.1s cubic-bezier(0, 0, 0.2, 1);
}
.gsn-progress-line.top { top: 0; }
.gsn-progress-line.bottom { bottom: 0; }
.gsn-progress-line.no-transition { transition: none !important; }
.gsn-progress-line.complete {
filter: brightness(1.8) saturate(1.5) !important;
box-shadow: 0 0 12px 1px rgba(255, 255, 255, 0.9), 0 0 5px rgba(255, 0, 0, 0.8) !important;
}
/* --- 設定面板樣式 --- */
#gsn-settings-overlay {
position: fixed; top: 0; left: 0; width: 100%; height: 100%;
background: rgba(0,0,0,0.5); z-index: 10000;
display: flex; align-items: center; justify-content: center;
font-family: system-ui, -apple-system, sans-serif;
}
.gsn-container {
background: rgba(255, 255, 255, 0.7) !important;
backdrop-filter: blur(15px) saturate(180%) !important;
-webkit-backdrop-filter: blur(15px) saturate(180%) !important;
padding: 30px !important; border-radius: 20px; width: 90%; max-width: 600px;
box-shadow: 0 10px 40px rgba(0,0,0,0.2) !important;
border: 1px solid rgba(255, 255, 255, 0.5) !important; border-top: 5px solid #007bff;
max-height: 90vh; overflow-y: auto;
}
.gsn-title { margin: 0 0 20px 0; text-align: center; color: #222; font-size: 1.5rem; border-bottom: 1px solid #eee; padding-bottom: 12px; }
.gsn-section { margin-bottom: 20px; }
.gsn-section-title { color: #007bff; font-weight: bold; font-size: 0.85rem; text-transform: uppercase; letter-spacing: 0.5px; margin-bottom: 8px; }
.gsn-label { display: block; margin-bottom: 8px; font-size: 0.95rem; color: #444 !important; }
.gsn-input-num {
width: 100% !important; padding: 10px !important; border: 1.5px solid #ddd !important;
border-radius: 6px !important; box-sizing: border-box !important; font-size: 1rem !important;
color: #333 !important; background: #ffffff !important; color-scheme: light !important;
box-shadow: none !important; outline: none !important;
}
.gsn-input-num:focus { border-color: #007bff !important; box-shadow: 0 0 0 2px rgba(0,123,255,0.25) !important; }
.gsn-input-range { width: 100%; cursor: pointer; margin-top: 8px; }
.gsn-display-val { text-align: right; font-size: 0.85rem; color: #666; margin-top: 4px; }
.gsn-checkbox-group { display: flex; align-items: center; margin-bottom: 10px; }
.gsn-checkbox-group input[type="checkbox"] { margin-right: 10px; width: 18px; height: 18px; cursor: pointer; }
.gsn-checkbox-group label { cursor: pointer; font-size: 0.95rem; color: #444 !important; margin: 0; }
.gsn-btn-group { display: flex; gap: 12px; margin-top: 25px; justify-content: flex-end; }
.gsn-btn { flex: 0 0 100px; padding: 12px; border: none; border-radius: 6px; font-weight: bold; cursor: pointer; transition: filter 0.2s; }
.gsn-btn:active { opacity: 0.8; }
.gsn-btn-save { background: #007bff; color: white; }
.gsn-btn-cancel { background: #f0f0f0; color: #444; }
textarea { color: #333 !important; background: #fff !important; font-family: monospace !important; font-size: 0.85rem !important; padding: 10px !important; min-height: 120px; }
#gsn-del-site, #gsn-reset { background-color: #dc3545; color: white; padding: 8px 16px; border-radius: 4px; cursor: pointer; font-size: 14px; }
#gsn-del-site:hover, #gsn-reset:hover, #gsn-save-all:hover, #gsn-cancel:hover { filter: brightness(0.9); }
`,
getHTML: (settings) => `
<div class="gsn-container">
<h2 class="gsn-title">Configuration</h2>
<div class="gsn-section">
<p class="gsn-section-title">Sensitivity</p>
<label class="gsn-label">(Desktop) Mouse Wheel Scrolls (1-20):</label>
<input type="number" id="gsn-mouse-val" class="gsn-input-num" value="${settings.mouseScrolls}">
<label class="gsn-label" style="margin-top:10px;">(Mobile) Touch Sensitivity (1-10):</label>
<input type="range" id="gsn-touch-val" class="gsn-input-range" min="1" max="10" value="${settings.touchSensitivity}">
<div class="gsn-display-val">Value: <span id="gsn-touch-display">${settings.touchSensitivity}</span></div>
</div>
<div class="gsn-section">
<p class="gsn-section-title">Scroller Behaviour</p>
<div class="gsn-checkbox-group">
<input type="checkbox" id="gsn-disable-refresh" ${settings.disableMobileRefresh ? 'checked' : ''}>
<label for="gsn-disable-refresh">Disable Pull-to-Refresh (Mobile)</label>
</div>
<div class="gsn-checkbox-group">
<input type="checkbox" id="gsn-disable-prev" ${settings.disablePrev ? 'checked' : ''}>
<label for="gsn-disable-prev">Disable Previous Page Navigation (Only allow Next Page)</label>
</div>
<div class="gsn-checkbox-group">
<input type="checkbox" id="gsn-accumulate-mode" ${settings.accumulateMode ? 'checked' : ''}>
<label for="gsn-accumulate-mode">Enable Page Turning Progress Accumulate Mode <br>
(won't reset progress bar until screen position leaving trigger zone)</label>
</div>
</div>
<div class="gsn-section">
<p class="gsn-section-title">Site Manager</p>
<div style="display: flex; gap: 5px; margin-bottom: 10px;">
<select id="gsn-site-select" class="gsn-input-num"><option value="">-- Add New Site --</option></select>
<button id="gsn-del-site" class="gsn-btn gsn-btn-cancel" style="flex: 0 0 60px;">Del</button>
</div>
<input type="text" id="gsn-site-name" placeholder="Site Name" class="gsn-input-num" style="margin-bottom: 5px;">
<input type="text" id="gsn-site-host" placeholder="Host (e.g. example.com)" class="gsn-input-num" style="margin-bottom: 5px;">
<textarea id="gsn-site-code" placeholder="/* Function Body of Page Turning Function: getPageButton(direction) {...} */ Example: return document.querySelector('.next-btn'); * @param {string} direction: 'next' or 'prev', indicate the page turning direction. * @returns {HTMLElement}: clickable element (next/previous page button) depending on direction. * Note: Return null if clickable element doesn't exist. (e.g., no previous page in the 1st page)" class="gsn-input-num" rows="11"></textarea>
</div>
<div class="gsn-btn-group">
<button id="gsn-reset" class="gsn-btn gsn-btn-cancel">Reset</button>
<button id="gsn-cancel" class="gsn-btn gsn-btn-cancel">Close</button>
<button id="gsn-save-all" class="gsn-btn gsn-btn-save">Save</button>
</div>
</div>
`
}
};
/* ==========================================================================
1. Logger 模組:負責統一處理終端機日誌輸出
========================================================================== */
const Logger = {
info: (message) => console.log(`[Scroll Pager]: ${message}`),
error: (message, err) => console.error(`[Scroll Pager Error]: ${message}`, err)
};
/* ==========================================================================
2. Store 模組:負責與 Tampermonkey 的存儲 API (GM_setValue/getValue) 溝通
========================================================================== */
const Store = {
getSetting: (key) => GM_getValue(key, Config.DefaultSettings[key]),
setSetting: (key, value) => GM_setValue(key, value),
getSites: () => {
const stored = GM_getValue('customSites', null);
return stored ? JSON.parse(stored) : Config.DefaultSites;
},
saveSites: (sites) => GM_setValue('customSites', JSON.stringify(sites)),
resetAll: () => {
GM_deleteValue('customSites');
GM_deleteValue('mouseScrolls');
GM_deleteValue('touchSensitivity');
GM_deleteValue('disableMobileRefresh');
GM_deleteValue('disablePrev');
GM_deleteValue('accumulateMode');
}
};
/* ==========================================================================
3. Progress 模組:負責在畫面上建立、更新及重置頂部/底部的紅色進度條
========================================================================== */
const Progress = {
bars: { top: null, bottom: null },
init: () => {
Progress.bars.bottom = Progress.createBar('bottom');
Progress.bars.top = Progress.createBar('top');
},
createBar: (position) => {
const bar = document.createElement('div');
// 只依賴 CSS class,不再 hardcode 寫入 style
bar.classList.add('gsn-progress-line', position);
document.body.appendChild(bar);
return bar;
},
update: (bar, currentProgress, maxProgress) => {
if (!bar) return;
const scale = Math.min(currentProgress / maxProgress, 1);
bar.style.transform = `scaleX(${scale})`;
// 當進度達到 100% 時加上 complete 類別,否則移除
if (scale >= 1) bar.classList.add('complete');
else bar.classList.remove('complete');
},
reset: () => {
[Progress.bars.top, Progress.bars.bottom].forEach(bar => {
if (!bar) return;
bar.classList.add('no-transition');
bar.classList.remove('complete'); // 重置時移除發光效果
bar.style.transform = 'scaleX(0)';
void bar.offsetWidth;
bar.classList.remove('no-transition');
});
}
};
/* ==========================================================================
4. Navigator 模組:負責尋找當前網站的目標按鈕,並執行點擊翻頁動作
========================================================================== */
const Navigator = {
getCurrentSite: () => {
const site = Store.getSites().find(s => location.host === s.host);
if (!site) return null;
return {
host: site.host,
getPageButton: new Function('direction', site.code)
};
},
triggerPage: (direction) => {
const site = Navigator.getCurrentSite();
const btn = site?.getPageButton(direction);
if (btn) btn.click();
},
checkIsBottom: () => {
const scrollHeight = document.documentElement.scrollHeight;
const scrollTop = window.scrollY || document.documentElement.scrollTop;
return (scrollTop + window.innerHeight) >= (scrollHeight - Config.System.bottomTolerance);
},
checkIsTop: () => window.scrollY === 0
};
/* ==========================================================================
5. EventController 模組:負責監聽滑鼠滾輪與觸控事件,計算滾動進度
========================================================================== */
const EventController = {
state: {
scrollCounter: 0,
disablePaging: false,
touchStartY: 0,
lastTouchY: 0, // [修正] 初始化 lastTouchY
touchAccumulated: 0, // [修正] 初始化累計距離變數
wheelTimer: null,
isBound: false
},
resetState: () => {
// 清除計時器防止重複執行
if (EventController.state.wheelTimer) {
clearTimeout(EventController.state.wheelTimer);
EventController.state.wheelTimer = null;
}
EventController.state.scrollCounter = 0;
EventController.state.touchAccumulated = 0;
EventController.state.disablePaging = false; // 離開區域後,允許再次觸發翻頁
Progress.reset();
},
handleScrollIntent: (isScrollingDown) => {
if (EventController.state.disablePaging) return { valid: false };
const isBottom = Navigator.checkIsBottom();
const isTop = Navigator.checkIsTop();
const site = Navigator.getCurrentSite();
const intentPrev = isTop && !isScrollingDown && site?.getPageButton('prev');
const intentNext = isBottom && isScrollingDown && site?.getPageButton('next');
if (intentPrev || intentNext) {
return {
valid: true,
direction: intentPrev ? 'prev' : 'next',
bar: intentPrev ? Progress.bars.top : Progress.bars.bottom
};
}
EventController.resetState();
return { valid: false };
},
bindMouse: () => {
document.addEventListener("wheel", event => {
const isScrollingDown = (event.wheelDelta) ? event.wheelDelta < 0 : event.deltaY > 0;
const intent = EventController.handleScrollIntent(isScrollingDown);
// 1. 清除上一次的計時器 (如果有的話)
if (EventController.state.wheelTimer) {
clearTimeout(EventController.state.wheelTimer);
}
if (intent.valid) {
if (intent.direction === 'prev' && Store.getSetting('disablePrev')) return;
EventController.state.scrollCounter++;
const maxScrolls = Store.getSetting('mouseScrolls');
Progress.update(intent.bar, EventController.state.scrollCounter, maxScrolls);
if (EventController.state.scrollCounter >= maxScrolls) {
EventController.state.disablePaging = true;
Navigator.triggerPage(intent.direction);
}
// 2. [核心邏輯] 如果關閉累加模式,設定一個計時器
// 當使用者停止捲動 500 毫秒後,自動重置進度
if (!Store.getSetting('accumulateMode')) {
EventController.state.wheelTimer = setTimeout(() => {
// 再次確認是否仍未觸發翻頁,才執行重置
if (!EventController.state.disablePaging) {
EventController.resetState();
}
}, Config.System.wheelResetDelay);
}
} else {
// 如果不在觸發區,且非累加模式,立即重置
if (!Store.getSetting('accumulateMode')) {
EventController.resetState();
}
}
});
},
bindTouch: () => {
document.addEventListener("touchstart", e => {
EventController.state.touchStartY = e.touches[0].clientY;
EventController.state.lastTouchY = e.touches[0].clientY;
}, { passive: true });
document.addEventListener("touchmove", e => {
const touchCurrentY = e.touches[0].clientY;
const deltaY = touchCurrentY - EventController.state.lastTouchY;
const isScrollingDown = touchCurrentY < EventController.state.touchStartY;
const intent = EventController.handleScrollIntent(isScrollingDown);
if (intent.valid) {
if (intent.direction === 'prev' && Store.getSetting('disablePrev')) return;
if (intent.direction === 'prev' && Store.getSetting('disableMobileRefresh') && e.cancelable) {
e.preventDefault();
}
// 累加滑動位移
EventController.state.touchAccumulated += Math.abs(deltaY);
const sensitivity = Store.getSetting('touchSensitivity');
const maxDistance = Config.System.touchDistanceBaseline - (sensitivity * Config.System.touchDistanceStep);
Progress.update(intent.bar, EventController.state.touchAccumulated, maxDistance);
if (EventController.state.touchAccumulated >= maxDistance) {
EventController.state.disablePaging = true;
Navigator.triggerPage(intent.direction);
}
}
EventController.state.lastTouchY = touchCurrentY;
}, { passive: false });
// [新增] 監聽手指放開
document.addEventListener("touchend", () => {
// 如果使用者關閉了「累加模式」,手指放開時進度條歸零
if (!Store.getSetting('accumulateMode')) {
EventController.resetState();
}
});
}
};
/* ==========================================================================
6. UI 模組:負責產生、操作設定面板,以及動態注入 CSS
========================================================================== */
const UI = {
injectStyles: () => {
if (!document.getElementById('gsn-global-styles')) {
const styleEl = document.createElement('style');
styleEl.id = 'gsn-global-styles';
// 動態將 Config.System 的變數轉換為 CSS,並合併到原本的 CSS 模板中
const dynamicSystemCSS = `
.gsn-progress-line {
height: ${Config.System.progressBarHeight};
background-color: ${Config.System.progressBarColor};
z-index: ${Config.System.progressBarZIndex} !important;
}
`;
styleEl.textContent = Config.UI.CSS + dynamicSystemCSS;
document.head.appendChild(styleEl);
}
UI.applyDynamicClasses();
},
applyDynamicClasses: () => {
const disableRefresh = Store.getSetting('disableMobileRefresh');
if (disableRefresh) {
document.documentElement.classList.add('gsn-no-refresh');
document.body.classList.add('gsn-no-refresh');
} else {
document.documentElement.classList.remove('gsn-no-refresh');
document.body.classList.remove('gsn-no-refresh');
}
},
open: () => {
if (document.getElementById('gsn-settings-overlay')) return;
const overlay = document.createElement('div');
overlay.id = 'gsn-settings-overlay';
const currentSettings = {
mouseScrolls: Store.getSetting('mouseScrolls'),
touchSensitivity: Store.getSetting('touchSensitivity'),
disableMobileRefresh: Store.getSetting('disableMobileRefresh'),
disablePrev: Store.getSetting('disablePrev'),
accumulateMode: Store.getSetting('accumulateMode')
};
overlay.innerHTML = Config.UI.getHTML(currentSettings);
document.body.appendChild(overlay);
const touchInput = document.getElementById('gsn-touch-val');
const touchDisplay = document.getElementById('gsn-touch-display');
const select = document.getElementById('gsn-site-select');
const nameInput = document.getElementById('gsn-site-name');
const hostInput = document.getElementById('gsn-site-host');
const codeInput = document.getElementById('gsn-site-code');
touchInput.oninput = (e) => touchDisplay.innerText = e.target.value;
const refreshDropdown = () => {
select.innerHTML = '<option value="">-- Add New Site --</option>';
Store.getSites().forEach((s, i) => {
const opt = document.createElement('option');
opt.value = i;
opt.textContent = s.name;
select.appendChild(opt);
});
};
refreshDropdown();
select.onchange = (e) => {
const site = Store.getSites()[e.target.value];
nameInput.value = site?.name || '';
hostInput.value = site?.host || '';
codeInput.value = site?.code || '';
};
document.getElementById('gsn-del-site').onclick = () => {
if (select.value === "") return alert("Please select a site first.");
if (confirm(`Delete site profile: "${select.options[select.selectedIndex].text}"?`)) {
let sites = Store.getSites();
sites.splice(select.value, 1);
Store.saveSites(sites);
refreshDropdown();
nameInput.value = hostInput.value = codeInput.value = '';
}
};
document.getElementById('gsn-reset').onclick = () => {
if (confirm("Reset all settings to factory default?")) {
Store.resetAll();
location.reload();
}
};
document.getElementById('gsn-save-all').onclick = () => {
Store.setSetting('mouseScrolls', parseInt(document.getElementById('gsn-mouse-val').value));
Store.setSetting('touchSensitivity', parseInt(document.getElementById('gsn-touch-val').value));
Store.setSetting('disableMobileRefresh', document.getElementById('gsn-disable-refresh').checked);
Store.setSetting('disablePrev', document.getElementById('gsn-disable-prev').checked);
Store.setSetting('accumulateMode', document.getElementById('gsn-accumulate-mode').checked);
UI.applyDynamicClasses();
if (nameInput.value && hostInput.value && codeInput.value) {
let sites = Store.getSites();
if (select.value !== "") sites[select.value] = { name: nameInput.value, host: hostInput.value, code: codeInput.value };
else sites.push({ name: nameInput.value, host: hostInput.value, code: codeInput.value });
Store.saveSites(sites);
}
overlay.remove();
Logger.info('Settings saved!');
};
document.getElementById('gsn-cancel').onclick = () => { overlay.remove(); };
}
};
/* ==========================================================================
7. App 主程式:管理腳本啟動生命週期與動態觀察者
========================================================================== */
const App = {
tryBindEvents: () => {
if (EventController.state.isBound) return;
const site = Navigator.getCurrentSite();
if (!site) return;
if (site.getPageButton('next') || site.getPageButton('prev')) {
EventController.bindMouse();
EventController.bindTouch();
EventController.state.isBound = true;
Logger.info('Events bound successfully.');
}
},
startObserver: () => {
if (EventController.state.isBound) return;
const selectors = Config.System.observerSelectors;
let targetNode = document.body;
for (const selector of selectors) {
const found = document.querySelector(selector);
if (found) { targetNode = found; break; }
}
const observer = new MutationObserver((mutations, obs) => {
App.tryBindEvents();
if (EventController.state.isBound) {
obs.disconnect();
Logger.info('Observer disconnected: Buttons found.');
}
});
observer.observe(targetNode, { childList: true, subtree: true });
},
init: () => {
GM_registerMenuCommand(Config.System.menuName, UI.open);
UI.injectStyles();
Progress.init();
App.tryBindEvents();
App.startObserver();
window.addEventListener('load', App.tryBindEvents);
window.addEventListener('popstate', () => {
EventController.state.isBound = false;
App.tryBindEvents();
App.startObserver();
});
window.navigation?.addEventListener("navigate", () => {
EventController.state.disablePaging = false;
});
const safetyInterval = setInterval(() => {
if (EventController.state.isBound) return clearInterval(safetyInterval);
App.tryBindEvents();
}, Config.System.safetyIntervalMs);
}
};
App.init();
})();