// ==UserScript== // @name nhentai 漫画下拉阅读模式(2026 新版修复) // @namespace https://github/FENGLIAA // @version 0.5.4 // @description 适配 nhentai.net 2026 改版(SvelteKit + v2 API + 新 CDN),支持 nhentai.xxx 与 e-hentai/exhentai;多节点自动容错 + 超时切换 + 调试日志 // @author FENGLIAA // @license MIT // @match https://nhentai.net/g/* // @match https://nhentai.xxx/g/* // @match https://e-hentai.org/g/* // @match https://e-hentai.org/s/* // @match https://exhentai.org/g/* // @match https://exhentai.org/s/* // @grant GM_xmlhttpRequest // @grant GM_addStyle // @grant unsafeWindow // @run-at document-idle // ==/UserScript== (function () { 'use strict'; const HOST = location.hostname; const IS_NHENTAI = HOST === 'nhentai.net'; const IS_XXX = HOST === 'nhentai.xxx'; const IS_EH = HOST === 'e-hentai.org' || HOST === 'exhentai.org'; if (!IS_NHENTAI && !IS_XXX && !IS_EH) return; /* ---------- 调试日志(F12 控制台过滤 [nh-read] 查看) ---------- */ const DEBUG = true; const LOG_DETAIL = 8; // 前 8 页详细记录每次请求/超时/报错,其余页只记最终失败 function dbg(...args) { if (DEBUG) console.log('[nh-read]', ...args); } /* ---------- 工具 ---------- */ const sleep = ms => new Promise(r => setTimeout(r, ms)); function gmGetText(url, timeoutMs) { return new Promise((resolve, reject) => { GM_xmlhttpRequest({ method: 'GET', url, timeout: timeoutMs || 20000, onload: r => { if (r.status >= 200 && r.status < 300) resolve(r.responseText); else reject(new Error('HTTP ' + r.status + ': ' + url)); }, onerror: () => reject(new Error('网络错误: ' + url)), ontimeout: () => reject(new Error('超时: ' + url)), }); }); } function parseDoc(html) { return new DOMParser().parseFromString(html, 'text/html'); } // 并发受限队列(e-hentai 抓页用,避免触发限流) function makeQueue(limit) { let running = 0; const waiters = []; const next = () => { if (running >= limit || !waiters.length) return; running += 1; waiters.shift()(); }; return fn => new Promise((res, rej) => { waiters.push(async () => { try { res(await fn()); } catch (e) { rej(e); } running -= 1; next(); }); next(); }); } /* ---------- 数据源 ---------- */ // 新 nhentai.net:详情页内嵌画廊 JSON(SvelteKit 水合数据) function readEmbeddedGallery() { const w = (typeof unsafeWindow !== 'undefined' ? unsafeWindow : null) || window; let g = null; try { g = w._gallery || w.gallery; } catch (e) { /* ignore */ } if (g && g.media_id && Array.isArray(g.pages) && g.pages.length) return normGallery(g); const script = document.querySelector('script[data-sveltekit-fetched][data-url^="/api/v2/galleries/"]'); if (script) { try { const obj = JSON.parse(script.textContent); let body = obj && obj.body !== undefined ? obj.body : obj; if (typeof body === 'string') body = JSON.parse(body); if (body && body.media_id && Array.isArray(body.pages) && body.pages.length) return normGallery(body); } catch (e) { /* ignore */ } } return null; } function normGallery(g) { return { mediaId: g.media_id, paths: g.pages.map(p => (p && p.path) ? p.path : (p && p.number ? p.number + '.jpg' : '')), }; } // 旧版结构回退:缩略图 URL → 原图 URL(t{n}→i{n},去掉页号后的 t) function deriveFromThumbs() { const map = new Map(); document.querySelectorAll('#thumbnail-container img').forEach(img => { const src = img.getAttribute('data-src') || img.getAttribute('src') || img.src || ''; const m = /\/galleries\/[^/?#]+\/(\d+)t?\.([a-z0-9]+)$/i.exec(src); if (!m) return; const n = parseInt(m[1], 10); if (!n || map.has(n)) return; map.set(n, src .replace(/\/\/t(\d*)\.nhentai\./i, '//i$1.nhentai.') .replace(/(\d+)t\.([a-z0-9]+)$/i, '$1.$2')); }); if (!map.size) return null; const out = []; map.forEach((url, n) => { out[n - 1] = url; }); return out; } // 最后手段:从当前阅读页的大图 URL 推导全部页 function deriveFromViewerImg() { let src = ''; document.querySelectorAll(IS_XXX ? '#fimg' : '#image-container img').forEach(el => { if (!src) src = el.getAttribute('data-src') || el.getAttribute('src') || el.src || ''; }); const m = src.match(/^(https?:\/\/.+\/galleries\/[^/]+\/)\d+\.([a-z0-9]+)$/i); if (!m) return null; const txt = document.body.innerText || ''; const mm = txt.match(/(\d+)\s*pages?/i); const total = mm ? parseInt(mm[1], 10) : 0; if (!total || total > 2000) return null; const out = []; for (let n = 1; n <= total; n += 1) out.push(m[1] + n + '.' + m[2]); return out; } // 图片节点列表:优先 /api/v2/config,失败则用默认 i1-i4/i5/i7/i 全部兜底 let serverListPromise = null; function getServerList() { if (!serverListPromise) { serverListPromise = (async () => { const list = []; try { const cfg = JSON.parse(await gmGetText('https://nhentai.net/api/v2/config', 8000)); if (cfg && Array.isArray(cfg.image_servers)) { cfg.image_servers.forEach(s => { if (s && typeof s === 'string') list.push(s); }); } } catch (e) { /* 用默认节点 */ } ['i1', 'i2', 'i3', 'i4', 'i5', 'i7', 'i'].forEach(h => { const host = h + '.nhentai.net'; if (!list.some(s => String(s).indexOf(host) !== -1)) list.push('https://' + host); }); return list; })(); } return serverListPromise; } // 兼容多种写法: // server: i1.nhentai.net / https://i1.nhentai.net/galleries/{id} // path: 1.jpg 或 galleries/{media_id}/1.jpg(v2 API 实际返回自带前缀的路径) function buildImageUrl(server, mediaId, path) { let s = String(server || '').trim().replace(/\/+$/, ''); if (!s) return ''; if (!/^https?:\/\//i.test(s)) s = 'https://' + s; const p = String(path || '').replace(/^\/+/, ''); if (!p) return ''; if (/^galleries\//i.test(p)) return s + '/' + p; if (/\/galleries\/[^/]+$/i.test(s)) return s + '/' + p; if (/\/galleries\/?$/i.test(s)) return s + '/' + mediaId + '/' + p; return s + '/galleries/' + mediaId + '/' + p; } // e-hentai / exhentai:阅读页地址 /s/{hash}/{gid}-{n} 里的 hash 是【每页各不相同】的图片键。 // 旧版拿第 1 页的 hash 去拼后面所有页 → 第 2 页起全是空错误页(只有第一页能看), // 那并不是限流。正确做法:从画廊的缩略图分页(?p=0,1,2…)把每页真实的 /s/ 链接逐个收集回来。 async function ehCollectPages() { const mG = location.pathname.match(/^\/g\/(\d+)\/([0-9a-f]+)/i); const mS = location.pathname.match(/^\/s\/([0-9a-f]+)\/(\d+)-\d+/i); const gid = mG ? mG[1] : (mS ? mS[2] : null); if (!gid) return null; // 1) 定位画廊详情页(/s/ 阅读页里一定有指回 /g/ 的链接) let galleryUrl = null; if (mG) { galleryUrl = 'https://' + HOST + '/g/' + mG[1] + '/' + mG[2] + '/'; } else { const m = (document.body.innerHTML || '').match( /https?:\/\/(?:www\.)?(?:e-hentai|exhentai)\.org\/g\/\d+\/[0-9a-f]{10}\//i); if (m) galleryUrl = m[0]; } if (!galleryUrl) return null; const pageRe = new RegExp('/s/([0-9a-f]+)/' + gid + '-(\\d+)', 'i'); const found = new Map(); // 页号 -> /s/ 完整地址 const collect = (root, base) => { root.querySelectorAll('a[href]').forEach(a => { const href = a.getAttribute('href') || ''; if (href.indexOf('/s/') === -1) return; const mm = href.match(pageRe); if (!mm) return; const n = parseInt(mm[2], 10); if (found.has(n)) return; const u = new URL(href, base); if (u.hostname === HOST) found.set(n, u.href); }); }; // 总页数(兼容 Length:1,234 pages 这样的千分位) const grabTotal = t => { const mm = /Length:\s*([\d,]+)\s*pages/i.exec(t || ''); return mm ? parseInt(mm[1].replace(/,/g, ''), 10) : 0; }; let total = 0; // 2) 当前就在画廊页:先复用页面上已渲染的缩略图链接(省一次请求) if (mG) { collect(document, location.href); total = grabTotal(document.body.innerText || ''); } if (total > 3000) { dbg('页数异常(' + total + '),放弃'); return null; } // 3) 依次抓缩略图分页(?p= 从 0 开始),直到集齐或没有新链接 const startP = (mG && found.size > 0) ? 1 : 0; // 画廊页自身那一屏已经收过了 for (let p = startP; p < 400; p += 1) { if (total && found.size >= total) break; let html = null; try { html = await gmGetText(galleryUrl + '?p=' + p, 20000); } catch (e) { dbg('缩略图分页抓取失败 p=' + p, e && e.message); break; } const doc = parseDoc(html); const before = found.size; collect(doc, galleryUrl + '?p=' + p); if (!total) total = grabTotal(doc.body && (doc.body.innerText || doc.body.textContent)); if (found.size === before) break; // 翻到底了 await sleep(350); // 画廊页有加载频率限制,慢慢翻 } if (!found.size) return null; const last = total || Math.max.apply(null, Array.from(found.keys())); const out = []; for (let n = 1; n <= last; n += 1) { if (found.has(n)) out.push({ href: found.get(n), selector: '#img' }); } dbg('e-hentai 页面收集:需要 ' + last + ' 页,实际拿到 ' + out.length + ' 页'); return out; } /* ---------- 数据汇总 ---------- */ async function resolvePages() { if (IS_NHENTAI) { // 1) 页面内嵌 JSON(等 SvelteKit 水合,最多约 5 秒) for (let i = 0; i < 15; i += 1) { const g = readEmbeddedGallery(); if (g) { const servers = await getServerList(); return { kind: 'nh', mediaId: g.mediaId, pages: g.paths, servers }; } await sleep(350); } // 2) 缩略图回退 const thumbs = deriveFromThumbs(); if (thumbs) return { kind: 'direct', pages: thumbs }; // 3) v2 API const gid = (location.pathname.match(/\/g\/(\d+)/) || [])[1]; if (gid) { try { const api = JSON.parse(await gmGetText('https://nhentai.net/api/v2/galleries/' + gid, 15000)); if (api && api.media_id && Array.isArray(api.pages) && api.pages.length) { const servers = await getServerList(); return { kind: 'nh', mediaId: api.media_id, pages: api.pages.map(p => p.path || ''), servers }; } } catch (e) { /* ignore */ } } // 4) 阅读页大图推导 const list = deriveFromViewerImg(); if (list) return { kind: 'direct', pages: list }; return null; } if (IS_XXX) { // 详情页缩略图链接 → 懒加载逐页抓 #fimg const links = Array.from(document.querySelectorAll('.gt_th > a')); if (links.length) { return { kind: 'fetch', pages: links.map(a => ({ href: a.href, selector: '#fimg' })) }; } const list = deriveFromViewerImg(); if (list) return { kind: 'direct', pages: list }; return null; } if (IS_EH) { const pages = await ehCollectPages(); if (pages && pages.length) return { kind: 'fetch', pages }; return null; } return null; } /* ---------- UI ---------- */ GM_addStyle([ '#nh-read-mode { position: fixed; top: 0; left: 0; width: 100%; height: 100%;', ' background: #000; overflow-y: scroll; z-index: 2147483000; display: none; text-align: center; }', '#nh-read-mode img { width: 90%; max-width: 800px; margin: 20px auto; display: block;', ' background: #161616; min-height: 60px; }', '#nh-read-mode img[data-state="loading"] { height: 420px; object-fit: contain; }', '#nh-read-status { position: fixed; top: 12px; left: 50%; transform: translateX(-50%);', ' z-index: 2147483001; background: rgba(0,0,0,.85); color: #fff; padding: 6px 14px;', ' border-radius: 14px; font: 13px/1.5 sans-serif; display: none; max-width: 90%; text-align: center; }', '#nh-read-close { position: fixed; top: 10px; right: 10px; z-index: 2147483002;', ' padding: 6px 12px; background: #e74c3c; color: #fff; border: none; border-radius: 4px;', ' cursor: pointer; display: none; font-size: 14px; }', '#nh-read-open { position: fixed; bottom: 20px; right: 20px; z-index: 2147483002;', ' padding: 10px 14px; background: #27ae60; color: #fff; border: none; border-radius: 4px;', ' cursor: pointer; font-size: 14px; box-shadow: 0 2px 8px rgba(0,0,0,.4); }' ].join('\n')); const box = document.createElement('div'); box.id = 'nh-read-mode'; document.body.appendChild(box); const statusEl = document.createElement('div'); statusEl.id = 'nh-read-status'; document.body.appendChild(statusEl); const closeBtn = document.createElement('button'); closeBtn.id = 'nh-read-close'; closeBtn.textContent = '✖ 关闭阅读'; closeBtn.onclick = hideReader; document.body.appendChild(closeBtn); const openBtn = document.createElement('button'); openBtn.id = 'nh-read-open'; openBtn.textContent = '📖 下拉阅读'; openBtn.onclick = () => { showReader(); loadReader(); }; document.body.appendChild(openBtn); let state = 'idle'; // idle | loading | done | error const fetchQueue = makeQueue(2); function showReader() { box.style.display = 'block'; closeBtn.style.display = 'block'; openBtn.style.display = 'none'; } function hideReader() { box.style.display = 'none'; closeBtn.style.display = 'none'; openBtn.style.display = 'block'; setStatus(null); } function setStatus(text) { if (!text) { statusEl.style.display = 'none'; return; } statusEl.textContent = text; statusEl.style.display = 'block'; } document.addEventListener('keydown', e => { if (e.key === 'Escape' && box.style.display === 'block') hideReader(); }); /* ---------- 渲染 ---------- */ async function loadReader() { if (state === 'loading' || state === 'done') return; state = 'loading'; setStatus('正在解析页面…'); let data = null; try { data = await resolvePages(); } catch (e) { dbg('resolvePages 异常:', e && e.message); data = null; } if (!data || !data.pages || !data.pages.length) { state = 'error'; dbg('没找到图片数据'); setStatus('❌ 没找到图片数据:请在本子【详情页】打开再点“下拉阅读”;若仍失败可能是网络/Cloudflare 拦截,稍后重试。'); return; } dbg('数据源 kind=' + data.kind + ' 页数=' + data.pages.length + ' mediaId=' + (data.mediaId || '-') + ' servers=' + ((data.servers || []).slice(0, 4).join(', ') || '-')); if (data.kind === 'nh' && data.servers && data.servers.length && data.pages[0]) { dbg('第一页 URL 示例:', buildImageUrl(data.servers[0], data.mediaId, data.pages[0])); } renderPages(data); state = 'done'; setStatus(null); } function renderPages(data) { box.innerHTML = ''; const frag = document.createDocumentFragment(); data.pages.forEach((p, i) => frag.appendChild(createPageItem(data, p, i))); box.appendChild(frag); } // 逐节点尝试加载:加载失败或 10 秒超时都会切换下一个 CDN 节点 function applyServerChain(img, servers, mediaId, path, i) { let idx = 0; let settled = false; let timer = null; function next() { if (settled) return; idx += 1; if (idx < servers.length) { tryServer(idx); } else { settled = true; clearTimeout(timer); img.alt = '图片加载失败 (第 ' + (i + 1) + ' 页):所有节点都失败。若官网阅读页能看图,请检查代理是否放行 i*.nhentai.net'; dbg('P' + (i + 1) + ' 全部节点失败'); } } function tryServer(k) { if (settled) return; clearTimeout(timer); img.src = buildImageUrl(servers[k], mediaId, path); if (i < LOG_DETAIL) dbg('P' + (i + 1) + ' 请求 ' + servers[k]); timer = setTimeout(() => { if (i < LOG_DETAIL) dbg('P' + (i + 1) + ' 超时(10s) ' + servers[k] + ',切换下一个'); next(); }, 10000); } img.onerror = () => { if (!settled) { if (i < LOG_DETAIL) dbg('P' + (i + 1) + ' 报错 ' + servers[idx] + ',切换下一个'); next(); } }; img.onload = () => { settled = true; clearTimeout(timer); img.alt = ''; if (i < LOG_DETAIL) dbg('P' + (i + 1) + ' 加载成功 ' + servers[idx]); }; tryServer(0); } function createPageItem(data, p, i) { const img = document.createElement('img'); img.decoding = 'async'; if (data.kind === 'nh') { // nhentai:立即加载(不用 loading=lazy,固定滚动容器里可能永远不触发请求), // 失败或超时自动切换 CDN 节点 applyServerChain(img, data.servers, data.mediaId, p, i); } else if (data.kind === 'direct') { img.src = p; if (i < LOG_DETAIL) dbg('P' + (i + 1) + ' direct ' + p); img.onerror = () => { img.alt = '图片加载失败 (第 ' + (i + 1) + ' 页)'; dbg('P' + (i + 1) + ' direct 报错'); }; setTimeout(() => { if (!img.complete || !img.naturalWidth) { img.alt = '图片加载超时 (第 ' + (i + 1) + ' 页),节点可能被墙'; dbg('P' + (i + 1) + ' direct 超时(20s)'); } }, 20000); } else { // e-hentai / nhentai.xxx:滚动到附近才抓页 img.dataset.state = 'loading'; img.alt = '加载中 (第 ' + (i + 1) + ' 页)…'; observeLazy(img, p); } return img; } /* ---------- 懒加载抓页 ---------- */ let io = null; function observeLazy(img, p) { if (!('IntersectionObserver' in window)) { loadPageInto(img, p); return; } if (!io) { io = new IntersectionObserver(entries => { entries.forEach(en => { if (en.isIntersecting) { io.unobserve(en.target); loadPageInto(en.target, JSON.parse(en.target.dataset.page)); } }); }, { root: box, rootMargin: '800px 0px' }); } img.dataset.page = JSON.stringify(p); io.observe(img); } function loadPageInto(img, p) { if (img.dataset.state !== 'loading') return; fetchQueue(async () => { for (let attempt = 1; attempt <= 4; attempt += 1) { try { const html = await gmGetText(p.href, 30000); const doc = parseDoc(html); const el = doc.querySelector(p.selector); const raw = el && (el.getAttribute('data-src') || el.getAttribute('src') || el.src); if (!raw) throw new Error('no img'); img.src = /^https?:/i.test(raw) ? raw : new URL(raw, p.href).href; img.onload = () => { img.dataset.state = 'done'; img.alt = ''; dbg('fetch 成功 ' + p.href); }; img.onerror = () => { img.dataset.state = 'failed'; img.alt = '图片加载失败'; dbg('fetch 图片加载失败 ' + p.href); }; return; } catch (err) { dbg('fetch 抓页失败 attempt=' + attempt + ' ' + p.href, (err && err.message) || err); await sleep(1200 * attempt); } } img.dataset.state = 'failed'; img.alt = '加载失败(可能被限流),点击重试'; img.style.cursor = 'pointer'; img.onclick = () => { img.dataset.state = 'loading'; img.alt = '加载中…'; img.onclick = null; loadPageInto(img, p); }; }); } })();