Télécharge nhentai.net, x-manga.org et ortegascans.fr en .cbz
// ==UserScript==
// @name CBZ Downloader (Multi-site)
// @namespace local.cbz.downloader
// @version 0.4.8
// @description Télécharge nhentai.net, x-manga.org et ortegascans.fr en .cbz
// @description:fr Télécharge nhentai.net, x-manga.org et ortegascans.fr en .cbz
// @license MIT
// @author You
// @match https://nhentai.net/*
// @match https://x-manga.org/*
// @match https://x-manga.net/*
// @match https://ortegascans.fr/*
// @match https://www.ortegascans.fr/*
// @match https://*.ortegascans.fr/*
// @grant GM_xmlhttpRequest
// @grant GM_addStyle
// @grant GM_getValue
// @grant GM_setValue
// @grant unsafeWindow
// @connect nhentai.net
// @connect i.nhentai.net
// @connect i1.nhentai.net
// @connect i2.nhentai.net
// @connect i3.nhentai.net
// @connect i4.nhentai.net
// @connect i5.nhentai.net
// @connect i6.nhentai.net
// @connect i7.nhentai.net
// @connect x-manga.org
// @connect x-manga.net
// @connect reader.hentai.gifts
// @connect ortegascans.fr
// @connect *
// @run-at document-idle
// @noframes
// ==/UserScript==
(function () {
"use strict";
if (window.__cbzLoaded) { console.log("[CBZ] déjà chargé, 2e copie ignorée"); return; }
window.__cbzLoaded = true;
const IS_NHENTAI = /nhentai\./.test(location.hostname);
const IS_XMANGA = /x-manga\.(org|net)/.test(location.hostname);
const IS_ORTEGA = /ortega/i.test(location.hostname);
const CHAP_RE = /\/(chapitre|chapter|capitulo)[\/-]?\d+/i;
const HOSTS = ["i.nhentai.net","i1.nhentai.net","i2.nhentai.net","i3.nhentai.net","i4.nhentai.net","i5.nhentai.net","i6.nhentai.net","i7.nhentai.net"];
const EXT = { j:"jpg", p:"png", g:"gif", w:"webp" };
const sleep = ms => new Promise(r => setTimeout(r, ms));
const sanitize = s => s.replace(/[/\\:*?"<>|]/g,"").replace(/\s+/g," ").trim();
const normUrl = u => (u || "").replace(/\/+$/,"").toLowerCase();
const log = (...a) => console.log("[CBZ]", ...a);
GM_addStyle(`
.cbz-dl-btn{margin:4px;padding:6px 14px;border:0;border-radius:4px;background:#ed2553;color:#fff;font-weight:700;cursor:pointer;font-size:13px;white-space:nowrap;display:inline-block}
.cbz-dl-btn:disabled{opacity:.75;cursor:wait}
.cbz-dl-btn.cbz-ok{background:#2e7d32}
.cbz-dl-btn.cbz-err{background:#c62828}
.cbz-dl-btn.cbz-small{position:absolute;top:2px;right:2px;z-index:5;margin:0;padding:3px 7px;font-size:11px;border-radius:3px;white-space:nowrap}
#cbz-bulk-btn{margin:6px 10px;vertical-align:middle;flex-shrink:0;white-space:nowrap;width:auto;min-width:150px}
#cbz-bulk-btn.cbz-float{position:fixed;bottom:90px;right:20px;z-index:2147483646;margin:0;box-shadow:0 4px 12px rgba(0,0,0,.5)}
#cbz-chap-btn{margin:10px;padding:8px 16px;font-size:14px}
#cbz-manage-btn{position:fixed;bottom:140px;right:20px;z-index:2147483646;margin:0;box-shadow:0 4px 12px rgba(0,0,0,.5);background:#37474f}
#cbz-panel{position:fixed;top:20vh;right:0;z-index:2147483647;width:280px;background:rgba(0,0,0,.9);color:#fff;font-size:12px;border-radius:6px 0 0 6px;padding:10px 12px;font-family:sans-serif}
#cbz-panel .cbz-title{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:6px;font-weight:700}
#cbz-panel .cbz-bar{height:10px;background:#333;border-radius:5px;overflow:hidden}
#cbz-panel .cbz-bar-fill{height:100%;width:0%;background:#ed2553;transition:width .2s}
#cbz-panel .cbz-bar-fill.zip{background:#66bb6a}
#cbz-panel .cbz-status{margin-top:4px;text-align:right;font-size:11px}
#cbz-panel .cbz-detail{margin-top:6px;font-size:11px;opacity:.8}
#cbz-toasts{position:fixed;right:16px;bottom:16px;z-index:2147483647;display:flex;flex-direction:column;gap:8px;max-width:340px}
.cbz-toast{padding:10px 14px;border-radius:6px;color:#fff;font-size:13px;font-family:sans-serif;box-shadow:0 2px 10px rgba(0,0,0,.5)}
.cbz-toast--success{background:#2e7d32}
.cbz-toast--error{background:#c62828}
.cbz-toast--info{background:#37474f}
.cbz-toast--warning{background:#f59e0b}
#cbz-stop{position:fixed;bottom:20px;left:20px;z-index:2147483647;padding:10px 18px;background:#c62828;color:#fff;border:0;border-radius:6px;font-weight:700;cursor:pointer;font-size:13px}
#cbz-lib-dialog{position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;font-family:sans-serif}
#cbz-lib-dialog .cbz-lib-box{background:#1c1c1c;color:#fff;border-radius:8px;padding:16px 20px;max-width:520px;width:calc(100% - 40px);max-height:80vh;display:flex;flex-direction:column}
#cbz-lib-dialog .cbz-lib-title{font-weight:700;margin-bottom:8px}
#cbz-lib-dialog .cbz-lib-list{overflow:auto;background:#111;border-radius:4px;padding:8px;flex:1;min-height:120px}
#cbz-lib-dialog .cbz-lib-row{display:flex;align-items:center;gap:8px;padding:4px 2px;border-bottom:1px solid #222;font-size:12px}
#cbz-lib-dialog .cbz-lib-name{flex:1;word-break:break-all}
#cbz-lib-dialog .cbz-lib-del{background:#c62828;border:0;color:#fff;border-radius:3px;padding:2px 8px;cursor:pointer;font-size:11px}
#cbz-lib-dialog .cbz-lib-actions{display:flex;gap:8px;margin-top:10px}
#cbz-lib-dialog .cbz-lib-actions button{border:0;border-radius:4px;padding:8px 12px;font-weight:700;cursor:pointer;color:#fff}
#cbz-lib-clear{background:#c62828}
#cbz-lib-close{background:#555}
`);
const LIB_KEY = "cbz_library";
let library = {};
try { library = JSON.parse(GM_getValue(LIB_KEY,"{}")) || {}; } catch(e) {}
const saveLibrary = () => GM_setValue(LIB_KEY, JSON.stringify(library));
const libGet = n => library[n];
const libSet = (n,e) => { library[n] = e; saveLibrary(); updateManageCount(); };
function updateManageCount() {
const b = document.getElementById("cbz-manage-btn");
if (b) b.textContent = "🗑 CBZ (" + Object.keys(library).length + ")";
}
let toastBox = null;
function showToast(type, text, timeout) {
if (!toastBox) { toastBox = document.createElement("div"); toastBox.id = "cbz-toasts"; document.body.appendChild(toastBox); }
const t = document.createElement("div");
t.className = "cbz-toast cbz-toast--" + type;
t.textContent = text;
toastBox.appendChild(t);
setTimeout(() => t.remove(), timeout || 5000);
}
let panel = null;
function showPanel(title) {
hidePanel(0);
panel = document.createElement("div");
panel.id = "cbz-panel";
panel.innerHTML = '<div class="cbz-title"></div><div class="cbz-bar"><div class="cbz-bar-fill"></div></div><div class="cbz-status"></div><div class="cbz-detail"></div>';
panel.querySelector(".cbz-title").textContent = title;
document.body.appendChild(panel);
}
function updatePanel(p, s, mode, detail) {
if (!panel) return;
const f = panel.querySelector(".cbz-bar-fill");
f.style.width = p + "%";
f.classList.toggle("zip", mode === "zip");
panel.querySelector(".cbz-status").textContent = s;
if (detail !== undefined) panel.querySelector(".cbz-detail").textContent = detail;
}
function hidePanel(d = 0) {
if (!panel) return;
const p = panel; panel = null;
if (d <= 0) p.remove(); else setTimeout(() => p.remove(), d);
}
function req(url, type = "text", headers = {}) {
return new Promise((res, rej) => {
GM_xmlhttpRequest({
method: "GET", url: url, responseType: type, timeout: 30000, headers: headers,
onload: r => {
if (r.status === 429) rej(new Error("HTTP 429"));
else if (r.status >= 200 && r.status < 300) res(r.response);
else rej(new Error("HTTP " + r.status + " : " + url));
},
onerror: () => rej(new Error("Erreur réseau : " + url)),
ontimeout: () => rej(new Error("Timeout : " + url))
});
});
}
const getJSON = u => req(u).then(t => JSON.parse(t));
/* ===== ZIP asynchrone ===== */
let CRC_TABLE = null;
function getCrcTable() {
if (CRC_TABLE) return CRC_TABLE;
const T = new Uint32Array(256);
for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); T[n] = c; }
CRC_TABLE = T; return T;
}
function crc32(u8) {
const T = getCrcTable();
let c = 0xFFFFFFFF;
for (let i = 0; i < u8.length; i++) c = T[(c ^ u8[i]) & 255] ^ (c >>> 8);
return (c ^ 0xFFFFFFFF) >>> 0;
}
const nameBytes = name => new TextEncoder().encode(name);
async function compress(files, onProgress) {
const parts = [], central = [];
let offset = 0;
for (let i = 0; i < files.length; i++) {
const f = files[i];
const u8 = new Uint8Array(f.data);
const nb = nameBytes(f.name);
const crc = crc32(u8);
const lh = new DataView(new ArrayBuffer(30));
lh.setUint32(0,0x04034b50,true); lh.setUint16(4,20,true); lh.setUint16(6,0,true); lh.setUint16(8,0,true);
lh.setUint16(10,0,true); lh.setUint16(12,0,true); lh.setUint32(14,crc,true);
lh.setUint32(18,u8.length,true); lh.setUint32(22,u8.length,true);
lh.setUint16(26,nb.length,true); lh.setUint16(28,0,true);
parts.push(new Uint8Array(lh.buffer), nb, u8);
const ch = new DataView(new ArrayBuffer(46));
ch.setUint32(0,0x02014b50,true); ch.setUint16(4,20,true); ch.setUint16(6,20,true); ch.setUint16(8,0,true);
ch.setUint16(10,0,true); ch.setUint16(12,0,true); ch.setUint16(14,0,true); ch.setUint32(16,crc,true);
ch.setUint32(20,u8.length,true); ch.setUint32(24,u8.length,true);
ch.setUint16(28,nb.length,true); ch.setUint16(30,0,true); ch.setUint16(32,0,true);
ch.setUint16(34,0,true); ch.setUint16(36,0,true); ch.setUint32(38,0,true); ch.setUint32(42,offset,true);
central.push(new Uint8Array(ch.buffer), nb);
offset += 30 + nb.length + u8.length;
if (i % 3 === 0) { if (onProgress) onProgress(Math.round((i / files.length) * 100)); await sleep(0); }
}
let centralSize = 0; for (const c of central) centralSize += c.length;
const eocd = new DataView(new ArrayBuffer(22));
eocd.setUint32(0,0x06054b50,true); eocd.setUint16(4,0,true); eocd.setUint16(6,0,true);
eocd.setUint16(8,files.length,true); eocd.setUint16(10,files.length,true);
eocd.setUint32(12,centralSize,true); eocd.setUint32(16,offset,true); eocd.setUint16(20,0,true);
parts.push(...central, new Uint8Array(eocd.buffer));
if (onProgress) onProgress(100);
return new Blob(parts, { type: "application/zip" });
}
function saveBlob(name, blob) {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = name;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 5000);
}
function makeBtn(txt) {
const b = document.createElement("button");
b.className = "cbz-dl-btn";
b.textContent = txt;
return b;
}
/* ===== Gestion bibliothèque ===== */
function openLibraryManager() {
const old = document.getElementById("cbz-lib-dialog"); if (old) old.remove();
const box = document.createElement("div"); box.id = "cbz-lib-dialog";
box.innerHTML = '<div class="cbz-lib-box"><div class="cbz-lib-title">Bibliothèque CBZ (<span class="cbz-lib-count"></span>)</div><div class="cbz-lib-list"></div><div class="cbz-lib-actions"><button id="cbz-lib-clear">Tout effacer</button><button id="cbz-lib-close">Fermer</button></div></div>';
document.body.appendChild(box);
const list = box.querySelector(".cbz-lib-list");
const count = box.querySelector(".cbz-lib-count");
function render() {
const names = Object.keys(library).sort();
count.textContent = names.length;
list.innerHTML = "";
if (!names.length) { list.innerHTML = '<div style="opacity:.7;font-size:12px">Aucune entrée.</div>'; return; }
names.forEach(n => {
const row = document.createElement("div"); row.className = "cbz-lib-row";
const nm = document.createElement("div"); nm.className = "cbz-lib-name"; nm.textContent = n;
const del = document.createElement("button"); del.className = "cbz-lib-del"; del.textContent = "✕";
del.onclick = () => { delete library[n]; saveLibrary(); render(); updateManageCount(); };
row.appendChild(nm); row.appendChild(del); list.appendChild(row);
});
}
render();
box.querySelector("#cbz-lib-clear").onclick = () => { library = {}; saveLibrary(); render(); updateManageCount(); showToast("info", "Bibliothèque effacée"); };
box.querySelector("#cbz-lib-close").onclick = () => box.remove();
box.addEventListener("click", e => { if (e.target === box) box.remove(); });
}
function initManageButton() {
if (document.getElementById("cbz-manage-btn")) return;
const b = makeBtn("🗑 CBZ");
b.id = "cbz-manage-btn";
b.onclick = e => { e.preventDefault(); e.stopPropagation(); openLibraryManager(); };
document.body.appendChild(b);
updateManageCount();
}
/* ===== Téléchargement d'images générique ===== */
async function downloadImagesSequential(urls, onStatus) {
const results = [];
for (let i = 0; i < urls.length; i++) {
const u = urls[i];
let data = null;
try { data = await req(u, "arraybuffer"); } catch (e) {}
if (!data) { try { data = await req(u, "arraybuffer", { Referer: location.origin }); } catch (e) {} }
if (!data) throw new Error("Échec image " + (i + 1));
const ext = (u.match(/\.(png|jpe?g|webp|gif)$/i) || [, "jpg"])[1].replace("jpeg", "jpg");
results.push({ name: String(i + 1).padStart(3, "0") + "." + ext, data });
onStatus(i + 1 + "/" + urls.length, Math.round(((i + 1) / urls.length) * 100));
await sleep(150);
}
return compress(results, p => onStatus("Zip " + p + "%", p));
}
function waitForLiveImages(grab, timeout = 20000) {
return new Promise(res => {
const initial = grab();
if (initial.length) return res(initial);
const start = Date.now();
let scrolls = 0;
const check = () => {
const u = grab();
if (u.length) return res(u);
if (Date.now() - start > timeout || scrolls > 12) return res(u);
window.scrollTo(0, document.body.scrollHeight);
scrolls++;
setTimeout(check, 600);
};
setTimeout(check, 600);
});
}
const chapFile = (t, l) => sanitize(t + " - " + l) + ".cbz";
const chapNum = l => parseInt((l.match(/(\d+)/) || [])[1] || "0", 10);
const labelFromUrl = u => {
const m = u.match(/\/(chapitre|chapter|capitulo)[\/-]?(\d+)/i);
return m ? "Chapitre " + m[2] : u;
};
function stopAuto() {
GM_setValue("auto_mode", "");
GM_setValue("auto_queue", "[]");
const s = document.getElementById("cbz-stop");
if (s) s.remove();
}
/* ================= NHENTAI ================= */
const getLangFromTags = t => {
const x = (t||[]).find(y => y.type === "language" && y.name !== "translated");
return x ? x.name : undefined;
};
async function getGallery(gid) {
try {
const d = await getJSON("https://nhentai.net/api/v2/galleries/" + gid);
if (d && d.media_id && Array.isArray(d.pages)) {
return { mid: String(d.media_id), title: (d.title && (d.title.english || d.title.japanese || d.title.pretty)) || ("gallery-" + gid), lang: getLangFromTags(d.tags), pages: d.pages.map((p, i) => ({ i: i + 1, ext: (String(p.path).match(/\.(\w+)$/) || [])[1] || "jpg" })) };
}
} catch (e) {}
try {
const d = await getJSON("https://nhentai.net/api/gallery/" + gid);
if (d && d.media_id && d.images && d.images.pages) {
return { mid: String(d.media_id), title: (d.title && (d.title.english || d.title.japanese)) || ("gallery-" + gid), lang: getLangFromTags(d.tags), pages: d.images.pages.map((p, i) => ({ i: i + 1, ext: EXT[p.t] || "jpg" })) };
}
} catch (e) {}
throw new Error("Infos introuvables : " + gid);
}
async function getGalleryWithRetry(gid) {
for (let a = 0; a < 3; a++) {
try { return await getGallery(gid); }
catch (e) {
if (!/429|rate ?limit/i.test(e.message)) throw e;
if (a < 2) { showToast("warning", "Pause anti-robot 10s…"); await sleep(10000); }
else throw e;
}
}
}
async function fetchPageImage(mid, page) {
for (const h of HOSTS) {
try { return { data: await req("https://" + h + "/galleries/" + mid + "/" + page.i + "." + page.ext, "arraybuffer"), ext: page.ext }; }
catch (e) { if (/404/.test(e.message)) break; await sleep(400); }
}
for (const ext of ["jpg","png","webp","gif"]) {
if (ext === page.ext) continue;
try { return { data: await req("https://i.nhentai.net/galleries/" + mid + "/" + page.i + "." + ext, "arraybuffer"), ext }; } catch (e) {}
}
throw new Error("Échec page " + page.i);
}
async function buildGalleryBlob(g, onStatus) {
const total = g.pages.length, pad = String(total).length;
const results = new Array(total), queue = [...g.pages];
let done = 0;
const worker = async () => {
while (queue.length) {
const p = queue.shift();
const img = await fetchPageImage(g.mid, p);
results[p.i - 1] = { name: String(p.i).padStart(pad, "0") + "." + img.ext, data: img.data };
done++;
onStatus(done + "/" + total, (100 * done) / total, "dl");
}
};
await Promise.all(Array.from({ length: 4 }, worker));
return compress(results.filter(Boolean), p => onStatus("Zip " + p + "%", p, "zip"));
}
async function runNhentai(gid, btn, label) {
if (btn.dataset.busy) return;
btn.dataset.busy = "1"; btn.disabled = true; btn.classList.remove("cbz-ok","cbz-err");
try {
const g = await getGalleryWithRetry(gid);
showPanel(g.title);
const blob = await buildGalleryBlob(g, (t, p, m) => { btn.textContent = t; updatePanel(p, t, m); });
const filename = (sanitize(g.title) || ("gallery-" + g.mid)) + ".cbz";
saveBlob(filename, blob);
libSet(filename, { p: g.pages.length, s: blob.size, g: gid });
btn.textContent = "✓ Terminé"; btn.classList.add("cbz-ok");
updatePanel(100, "✓ Terminé", "zip"); hidePanel(3000);
showToast("success", "✓ " + filename);
} catch (e) {
btn.textContent = "✗ " + e.message; btn.classList.add("cbz-err");
updatePanel(100, "✗ Erreur"); hidePanel(4000);
showToast("error", "✗ " + e.message);
} finally { btn.disabled = false; delete btn.dataset.busy; }
}
function initNhentaiDetail() {
const gid = (location.pathname.match(/\/g\/(\d+)/) || [])[1];
const box = document.querySelector("#info .buttons") || document.querySelector("#info");
if (!gid || !box || box.querySelector("[data-cbz-detail]")) return;
const btn = makeBtn("Download .cbz");
btn.dataset.cbzDetail = "1";
box.appendChild(btn);
btn.onclick = () => runNhentai(gid, btn, "Download .cbz");
}
function initNhentaiList() {
document.querySelectorAll(".gallery").forEach(g => {
if (g.dataset.cbzInit) return;
const a = g.querySelector("a.cover") || g.querySelector("a");
if (!a) return;
const gid = ((a.getAttribute("href") || "").match(/\/g\/(\d+)/) || [])[1];
if (!gid) return;
g.dataset.cbzInit = "1";
const btn = makeBtn("⬇ CBZ");
btn.classList.add("cbz-small");
g.appendChild(btn);
btn.onclick = e => { e.preventDefault(); e.stopPropagation(); runNhentai(gid, btn, "⬇ CBZ"); };
});
}
function initNhentai() {
initNhentaiDetail();
initNhentaiList();
let timer = null;
new MutationObserver(() => {
clearTimeout(timer);
timer = setTimeout(() => { initNhentaiDetail(); initNhentaiList(); }, 200);
}).observe(document.body, { childList: true, subtree: true });
}
/* ================= ORTEGA (XHR, sans navigation) ================= */
let ortegaBulkRunning = false;
const makeOrtegaFilename = (title, num) => sanitize(title + " - Chapitre " + num) + ".cbz";
function ortegaTitleFromDOM() {
const bc = document.querySelector('nav[aria-label*="readcrumb"], .breadcrumb, [class*="breadcrumb"]');
if (bc) {
const items = [...bc.querySelectorAll("a,span,li")].map(x => x.textContent.trim()).filter(Boolean);
if (items.length >= 2) return items[items.length - 2] || items[items.length - 1];
}
const h1 = document.querySelector("h1");
if (h1 && !/chapitre/i.test(h1.textContent)) return h1.textContent.trim();
return document.title.split(/[|–-]/)[0].trim();
}
function parseOrtegaRSC(html) {
const chunkRegex = /self\.__next_f\.push\(\[\d+,"([\s\S]*?)"\]\)/g;
let match;
while ((match = chunkRegex.exec(html)) !== null) {
const chunkContent = match[1];
let decoded;
try { decoded = chunkContent.replace(/\\n/g,'\n').replace(/\\"/g,'"').replace(/\\\\/g,'\\'); }
catch (e) { continue; }
if (decoded.includes('"chapters"') && decoded.includes('"slug"')) {
const chapMatch = decoded.match(/"chapters":\s*\[([\s\S]*?)\]/);
if (chapMatch) { try { return JSON.parse('[' + chapMatch[1] + ']'); } catch (e) {} }
}
}
return [];
}
async function fetchOrtegaChapterImages(chapUrl) {
const html = await req(chapUrl, "text");
const imgRegex = /<img[^>]*src="([^"]*\/api\/chapters\/[^"]*\/image\/[^"]*)"[^>]*>/gi;
const urls = [];
let match;
while ((match = imgRegex.exec(html)) !== null) {
let src = match[1];
if (!src.startsWith('http')) src = location.origin + src;
urls.push(src);
}
return urls;
}
async function bulkDownloadOrtega() {
if (ortegaBulkRunning) { showToast("error", "Déjà en cours"); return; }
ortegaBulkRunning = true;
let stopRequested = false;
showPanel("Téléchargement groupé");
updatePanel(0, "Chargement de la liste…");
const title = ortegaTitleFromDOM();
const slug = location.pathname.match(/\/serie\/([^\/\?]+)/)?.[1];
const chapters = parseOrtegaRSC(document.documentElement.outerHTML)
.map(c => ({ number: c.number, url: location.origin + '/serie/' + slug + '/chapter/' + c.number, filename: makeOrtegaFilename(title, c.number) }))
.sort((a, b) => a.number - b.number);
if (!chapters.length) { showToast("error", "Aucun chapitre trouvé"); ortegaBulkRunning = false; hidePanel(2000); return; }
const remaining = chapters.filter(c => !libGet(c.filename));
if (!remaining.length) { showToast("info", "Déjà tout téléchargé (🗑 CBZ pour effacer)"); ortegaBulkRunning = false; hidePanel(2000); return; }
const stopBtn = document.createElement("button");
stopBtn.id = "cbz-stop"; stopBtn.textContent = "⏹ STOP";
stopBtn.onclick = () => { stopRequested = true; };
document.body.appendChild(stopBtn);
let done = 0, failed = 0;
for (let i = 0; i < remaining.length; i++) {
if (stopRequested) break;
const chap = remaining[i];
updatePanel((100 * i) / remaining.length, `${i + 1}/${remaining.length}`, "dl", "Chapitre " + chap.number);
try {
const imgs = await fetchOrtegaChapterImages(chap.url);
if (!imgs.length) { failed++; continue; }
const blob = await downloadImagesSequential(imgs, (t, p) => updatePanel(((i + p/100) / remaining.length) * 100, `${i + 1}/${remaining.length} — ${t}`, p >= 100 ? "zip" : "dl", "Chapitre " + chap.number));
saveBlob(chap.filename, blob);
libSet(chap.filename, { p: imgs.length, s: blob.size, g: chap.url });
done++;
showToast("success", "✓ Chapitre " + chap.number, 1200);
} catch (e) { failed++; showToast("error", "✗ Chapitre " + chap.number, 2000); }
await sleep(400);
}
stopBtn.remove(); ortegaBulkRunning = false;
updatePanel(100, `Terminé : ${done} ✓, ${failed} ✗`, "zip");
showToast("success", `Terminé : ${done} chapitres`, 8000);
setTimeout(() => hidePanel(3000), 3000);
}
async function downloadOrtegaSingleChapter() {
const m = location.pathname.match(/\/serie\/([^\/]+)\/chapter\/(\d+)/);
if (!m) { showToast("error", "URL non reconnue"); return; }
const num = parseInt(m[2], 10);
const btn = document.getElementById("cbz-chap-btn");
if (btn) { btn.disabled = true; btn.textContent = "…"; }
const title = ortegaTitleFromDOM();
const filename = makeOrtegaFilename(title, num);
showPanel(title);
updatePanel(0, "Chargement des images…", "dl", "Chapitre " + num);
try {
const imgs = await fetchOrtegaChapterImages(location.href);
if (!imgs.length) { showToast("error", "Aucune image"); hidePanel(2000); if (btn) { btn.disabled = false; btn.textContent = "⬇ Ce chapitre en CBZ"; } return; }
const blob = await downloadImagesSequential(imgs, (t, p) => updatePanel(p, t, p >= 100 ? "zip" : "dl", "Chapitre " + num));
saveBlob(filename, blob);
libSet(filename, { p: imgs.length, s: blob.size, g: location.href });
showToast("success", "✓ " + filename);
if (btn) { btn.disabled = false; btn.textContent = "✓ Téléchargé"; btn.classList.add("cbz-ok"); }
updatePanel(100, "✓ Terminé", "zip"); hidePanel(2000);
} catch (e) {
showToast("error", "Échec : " + e.message);
if (btn) { btn.disabled = false; btn.textContent = "✗ Erreur"; btn.classList.add("cbz-err"); }
hidePanel(3000);
}
}
function initOrtega() {
setTimeout(() => {
try {
if (/\/serie\/[^\/]+\/?$/.test(location.pathname) && !document.getElementById("cbz-bulk-btn")) {
const b = makeBtn("⬇ Tous les chapitres");
b.id = "cbz-bulk-btn"; b.classList.add("cbz-float");
document.body.appendChild(b);
b.onclick = e => { e.preventDefault(); e.stopPropagation(); bulkDownloadOrtega(); };
}
if (/\/serie\/[^\/]+\/chapter\/\d+/.test(location.pathname) && !document.getElementById("cbz-chap-btn")) {
const b = makeBtn("⬇ Ce chapitre en CBZ");
b.id = "cbz-chap-btn";
const main = document.querySelector("main") || document.body;
main.insertBefore(b, main.firstChild);
b.onclick = e => { e.preventDefault(); e.stopPropagation(); downloadOrtegaSingleChapter(); };
}
} catch (e) { log("init ortega erreur:", e); }
}, 1500);
}
/* ================= X-MANGA (rétabli, mode auto navigation) ================= */
function xmangaGrab() {
return [...new Set([...document.querySelectorAll("img")].map(i => i.dataset.src || i.src).filter(u => /reader\.hentai\.gifts\//.test(u)))];
}
function getXmangaSeriesInfo() {
const title = (document.querySelector("h1")?.textContent || document.title.split(/[|–-]/)[0]).trim();
const seen = new Map();
document.querySelectorAll("a").forEach(a => {
if (CHAP_RE.test(a.href) && !seen.has(a.href)) seen.set(a.href, { url: a.href, label: a.textContent.trim() || labelFromUrl(a.href) });
});
return { title, chapters: [...seen.values()] };
}
function xmangaStartAuto(info) {
const chapters = [...info.chapters].sort((a, b) => chapNum(a.label) - chapNum(b.label));
const remaining = chapters.filter(c => !libGet(chapFile(info.title, c.label)));
if (!remaining.length) { showToast("info", "Déjà tout téléchargé (🗑 CBZ pour effacer)"); return; }
GM_setValue("auto_mode", "xmanga");
GM_setValue("auto_title", info.title);
GM_setValue("auto_queue", JSON.stringify(remaining.map(c => ({ url: c.url, label: c.label }))));
showToast("info", "Démarrage auto (" + remaining.length + ")");
location.href = remaining[0].url;
}
async function xmangaAutoStep() {
let queue = JSON.parse(GM_getValue("auto_queue", "[]"));
const title = GM_getValue("auto_title", "série");
if (!queue.length) { stopAuto(); showToast("success", "Lot terminé"); return; }
if (!document.getElementById("cbz-stop")) {
const s = document.createElement("button");
s.id = "cbz-stop"; s.textContent = "⏹ STOP auto";
s.onclick = () => { stopAuto(); showToast("info", "Mode auto arrêté"); hidePanel(); };
document.body.appendChild(s);
}
const cur = normUrl(location.href);
let idx = queue.findIndex(q => normUrl(q.url) === cur);
const current = idx >= 0 ? queue[idx] : queue[0];
showPanel("Auto : " + title);
updatePanel(0, "Lecture des images…", "dl", current.label);
const urls = await waitForLiveImages(xmangaGrab);
if (urls.length) {
try {
const blob = await downloadImagesSequential(urls, (t, p) => updatePanel(p, t, p >= 100 ? "zip" : "dl", current.label));
const fname = chapFile(title, current.label);
saveBlob(fname, blob);
libSet(fname, { p: urls.length, s: blob.size, g: location.href });
showToast("success", "✓ " + current.label, 1500);
} catch (e) { showToast("error", "✗ " + current.label, 2000); }
} else {
showToast("warning", "Images introuvables : " + current.label);
}
if (idx >= 0) queue.splice(idx, 1); else queue.shift();
GM_setValue("auto_queue", JSON.stringify(queue));
if (queue.length) {
updatePanel(100, "→ suivant…", "zip");
await sleep(1500);
location.href = queue[0].url;
} else {
stopAuto(); hidePanel();
showToast("success", "Lot terminé", 8000);
}
}
function initXmanga() {
setTimeout(() => {
try {
if (GM_getValue("auto_mode") === "xmanga" && CHAP_RE.test(location.href)) {
xmangaAutoStep();
return;
}
if (CHAP_RE.test(location.href) && !document.getElementById("cbz-chap-btn")) {
const title = (document.querySelector("h1")?.textContent || document.title.split(/[|–-]/)[0]).trim();
const label = labelFromUrl(location.href);
const fname = chapFile(title, label);
const b = makeBtn("⬇ Ce chapitre en CBZ");
b.id = "cbz-chap-btn";
const main = document.querySelector("#main-container") || document.body;
main.insertBefore(b, main.firstChild);
b.onclick = async e => {
e.preventDefault(); e.stopPropagation();
b.disabled = true; b.textContent = "…";
showPanel(title);
updatePanel(0, "Lecture des images…", "dl", label);
try {
const urls = await waitForLiveImages(xmangaGrab);
if (!urls.length) { showToast("error", "Aucune image"); b.disabled = false; b.textContent = "⬇ Ce chapitre en CBZ"; hidePanel(2000); return; }
const blob = await downloadImagesSequential(urls, (t, p) => updatePanel(p, t, p >= 100 ? "zip" : "dl", label));
saveBlob(fname, blob);
libSet(fname, { p: urls.length, s: blob.size, g: location.href });
b.disabled = false; b.textContent = "✓ Téléchargé"; b.classList.add("cbz-ok");
showToast("success", "✓ " + fname);
updatePanel(100, "✓ Terminé", "zip"); hidePanel(2000);
} catch (err) {
b.disabled = false; b.textContent = "✗ Erreur"; b.classList.add("cbz-err");
showToast("error", "Échec : " + err.message); hidePanel(3000);
}
};
} else if (!CHAP_RE.test(location.href) && !document.getElementById("cbz-bulk-btn")) {
const b = makeBtn("⬇ Tous les chapitres (auto)");
b.id = "cbz-bulk-btn"; b.classList.add("cbz-float");
document.body.appendChild(b);
b.onclick = e => { e.preventDefault(); e.stopPropagation(); xmangaStartAuto(getXmangaSeriesInfo()); };
}
} catch (e) { log("init xmanga erreur:", e); }
}, 1500);
}
log("Script v0.4.8 chargé sur:", location.href);
initManageButton();
if (IS_NHENTAI) initNhentai();
else if (IS_ORTEGA) initOrtega();
else if (IS_XMANGA) initXmanga();
})();