// ==UserScript== // @name nhentai 漫画下拉阅读模式(2026 新版修复) // @namespace https://github/FENGLIAA // @version 0.5.3 // @description 适配 nhentai.net 2026 改版(SvelteKit + v2 API + 新 CDN),支持 nhentai.xxx 与 e-hentai;多节点自动容错 + 超时切换 + 调试日志 // @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== // 为什么旧版会裂图: // nhentai.net 2026-03 末大改版(SvelteKit 重写)—— // 1) 旧接口 api/gallery/{id} 与页面结构 #thumbnail-container a 失效; // 2) 图片 CDN 从裸 i.nhentai.net 改为 i1-i4.nhentai.net(裸 i. 经常失败); // 3) 逐页抓 HTML 撞上 Cloudflare,拿到挑战页解析不到图片。 // 新版改为:直接读页面内嵌的画廊 JSON(零请求),图片用原生 加载(浏览器自带 // cf 通行凭证),再配 i1-i4 多节点 onerror 自动切换,几乎不会再裂。 (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:由 sha/gid 生成 1..total 的阅读页地址 function ehGetPages() { const path = location.pathname; const mG = path.match(/^\/g\/(\d+)\/([0-9a-f]+)/i); const mS = path.match(/^\/s\/([0-9a-f]+)\/(\d+)-\d+/i); const gid = mS ? mS[2] : (mG ? mG[1] : null); let sha = mS ? mS[1] : null; if (!gid) return null; if (!sha) { const a = document.querySelector('#gdt a'); const m = a && (a.getAttribute('href') || '').match(/\/s\/([0-9a-f]+)\//i); if (!m) return null; sha = m[1]; } const txt = document.body.innerText || ''; const mm = txt.match(/Length:\s*(\d+)\s*pages/i) || txt.match(/Pages:\s*(\d+)/i) || txt.match(/Showing\s+[\d,\s–-]*of\s+(\d+)\s+images/i); let total = mm ? parseInt(mm[1], 10) : 0; if (!total) total = document.querySelectorAll('#gdt a').length; if (!total || total > 3000) return null; const out = []; for (let n = 1; n <= total; n += 1) out.push('https://' + HOST + '/s/' + sha + '/' + gid + '-' + n); 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 = ehGetPages(); if (pages) return { kind: 'fetch', pages: pages.map(href => ({ href, selector: '#img' })) }; 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); }; }); } })();