Télécharge les galeries nhentai et les séries x-manga.org en .cbz
// ==UserScript==
// @name CBZ Downloader
// @namespace local.cbz.downloader
// @version 0.2
// @description Télécharge les galeries nhentai et les séries x-manga.org en .cbz
// @license MIT
// @match https://nhentai.net/*
// @match https://x-manga.org/*
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @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 reader.hentai.gifts
// @run-at document-idle
// @noframes
// ==/UserScript==
(function () {
"use strict";
const THREADS = 4;
const EXT = { j: "jpg", p: "png", g: "gif", w: "webp" };
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 sleep = (ms) => new Promise((r) => setTimeout(r, ms));
const sanitize = (s) => s.replace(/[/\\:*?"<>|]/g, "").replace(/\s+/g, " ").trim();
const log = (...a) => console.log("[CBZ Downloader]", ...a);
const IS_NHENTAI = /nhentai\./.test(location.hostname);
const IS_XMANGA = /x-manga\.org/.test(location.hostname);
const IS_FIREFOX_SAFE = /Firefox\//.test(navigator.userAgent);
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}
.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{display:inline-block;margin-left:12px;vertical-align:middle}
.cbz-chap-btn{border:0;border-radius:3px;background:#ed2553;color:#fff;font-weight:700;cursor:pointer;font-size:10px;padding:1px 6px;margin-left:6px;vertical-align:middle}
.cbz-chap-btn.cbz-ok{background:#2e7d32}
.gallery{position:relative}
.gallery.cbz-done{box-shadow:0 0 0 2px #2e7d32}
.gallery.cbz-failed{box-shadow:0 0 0 2px #c62828}
#cbz-panel{position:fixed;top:20vh;right:0;z-index:2147483647;width:220px;background:rgba(0,0,0,.78);color:#fff;font-size:12px;border-radius:6px 0 0 6px;padding:8px 10px;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-bar-fill.err{background:#f44336}
#cbz-panel .cbz-status{margin-top:4px;text-align:right}
#cbz-toasts{position:fixed;right:16px;bottom:16px;z-index:2147483647;display:flex;flex-direction:column;gap:8px;align-items:stretch;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);opacity:1;transition:opacity .3s,transform .3s;word-break:break-word}
.cbz-toast--success{background:#2e7d32}
.cbz-toast--error{background:#c62828}
.cbz-toast--info{background:#37474f}
.cbz-toast--hide{opacity:0;transform:translateY(8px)}
#cbz-bulk-dialog,#cbz-replace-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-bulk-dialog .cbz-bulk-box,#cbz-replace-dialog .cbz-replace-box{background:#1c1c1c;color:#fff;border-radius:8px;padding:18px 22px;max-width:430px;width:calc(100% - 40px)}
#cbz-bulk-dialog .cbz-bulk-title,#cbz-replace-dialog .cbz-replace-title{font-size:15px;font-weight:700;margin-bottom:6px}
#cbz-bulk-dialog .cbz-bulk-sub{font-size:12px;opacity:.8;margin-bottom:12px}
#cbz-replace-dialog .cbz-replace-text{white-space:pre-wrap;background:#111;border-radius:4px;padding:8px;font-size:12px;margin-bottom:12px}
#cbz-bulk-dialog .cbz-bulk-buttons{display:flex;flex-direction:column;gap:8px}
#cbz-replace-dialog .cbz-replace-buttons{display:flex;gap:8px}
#cbz-bulk-dialog button,#cbz-replace-dialog button{border:0;border-radius:4px;padding:9px 12px;font-weight:700;cursor:pointer;color:#fff;background:#ed2553;font-size:13px}
#cbz-bulk-dialog button[data-f="english"]{background:#1976d2}
#cbz-bulk-dialog button[data-f="japanese"]{background:#7b1fa2}
#cbz-bulk-dialog button[data-f="chinese"]{background:#c62828}
#cbz-bulk-dialog button.cbz-bulk-cancel{background:#555}
#cbz-replace-dialog .cbz-replace-yes{background:#2e7d32}
#cbz-replace-dialog .cbz-replace-no{background:#555}
#cbz-report{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-report .cbz-report-box{background:#1c1c1c;color:#fff;border-radius:8px;padding:16px 20px;max-width:480px;width:calc(100% - 40px)}
#cbz-report .cbz-report-title{font-weight:700;margin-bottom:8px}
#cbz-report .cbz-report-text{white-space:pre-wrap;background:#111;border-radius:4px;padding:8px;font-size:12px;max-height:50vh;overflow:auto}
#cbz-report .cbz-report-ok{margin-top:10px;border:0;border-radius:4px;padding:8px 16px;background:#ed2553;color:#fff;font-weight:700;cursor:pointer}
`);
/* ============ MÉMOIRE PERSISTANTE ============ */
const STATUS_KEY = "cbz_dl_status";
let statusMap = {};
try { statusMap = JSON.parse(GM_getValue(STATUS_KEY, "{}")) || {}; } catch (e) { statusMap = {}; }
const saveStatus = () => GM_setValue(STATUS_KEY, JSON.stringify(statusMap));
const setStatus = (gid, st) => { statusMap[gid] = st; saveStatus(); };
const LIB_KEY = "cbz_library";
let library = {};
try { library = JSON.parse(GM_getValue(LIB_KEY, "{}")) || {}; } catch (e) { library = {}; }
const saveLibrary = () => GM_setValue(LIB_KEY, JSON.stringify(library));
const libGet = (name) => library[name];
const libSet = (name, entry) => { library[name] = entry; saveLibrary(); };
const formatSize = (b) => b >= 1048576 ? (b / 1048576).toFixed(1).replace(".", ",") + " Mo" : Math.round(b / 1024) + " Ko";
const btnRegistry = [];
function registerBtn(btn, gid, label) { btnRegistry.push({ btn, gid, label }); }
function repaintBtn(gid) { btnRegistry.forEach((r) => { if (String(r.gid) === String(gid)) paintStatus(r.btn, r.gid, r.label); }); }
function paintStatus(btn, gid, label) {
const st = statusMap[gid];
const gal = btn.closest(".gallery");
btn.classList.remove("cbz-ok", "cbz-err");
if (gal) gal.classList.remove("cbz-done", "cbz-failed");
if (st === "ok") { btn.classList.add("cbz-ok"); btn.textContent = "✓ CBZ"; if (gal) gal.classList.add("cbz-done"); }
else if (st === "err") { btn.classList.add("cbz-err"); btn.textContent = "✗ CBZ"; if (gal) gal.classList.add("cbz-failed"); }
else btn.textContent = label;
}
/* ============ INTERFACE ============ */
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.classList.add("cbz-toast--hide"); setTimeout(() => t.remove(), 350); }, timeout || (type === "error" ? 8000 : 5000));
}
function showReport(text) {
const old = document.getElementById("cbz-report");
if (old) old.remove();
const box = document.createElement("div");
box.id = "cbz-report";
box.innerHTML = '<div class="cbz-report-box"><div class="cbz-report-title">Rapport du lot</div><pre class="cbz-report-text"></pre><button class="cbz-report-ok">OK</button></div>';
box.querySelector(".cbz-report-text").textContent = text;
box.querySelector(".cbz-report-ok").addEventListener("click", () => box.remove());
document.body.appendChild(box);
}
function askReplace(filename, oldE, newE) {
return new Promise((resolve) => {
const old = document.getElementById("cbz-replace-dialog");
if (old) old.remove();
const box = document.createElement("div");
box.id = "cbz-replace-dialog";
box.innerHTML = '<div class="cbz-replace-box"><div class="cbz-replace-title">Version plus récente détectée</div><pre class="cbz-replace-text"></pre><div class="cbz-replace-buttons"><button class="cbz-replace-yes">Remplacer</button><button class="cbz-replace-no">Garder l\'ancien</button></div></div>';
box.querySelector(".cbz-replace-text").textContent = `${filename}\n\nDéjà présent : ${oldE.p} pages, ${formatSize(oldE.s)}\nNouveau : ${newE.p} pages, ${formatSize(newE.s)}`;
const done = (v) => { box.remove(); resolve(v); };
box.querySelector(".cbz-replace-yes").addEventListener("click", () => done(true));
box.querySelector(".cbz-replace-no").addEventListener("click", () => done(false));
document.body.appendChild(box);
});
}
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>';
panel.querySelector(".cbz-title").textContent = title;
document.body.appendChild(panel);
}
function updatePanel(percent, status, mode) {
if (!panel) return;
const fill = panel.querySelector(".cbz-bar-fill");
fill.style.width = percent + "%";
fill.classList.toggle("zip", mode === "zip");
fill.classList.toggle("err", mode === "err");
panel.querySelector(".cbz-status").textContent = status;
}
function hidePanel(delay = 3000) {
if (!panel) return;
const p = panel; panel = null;
if (delay <= 0) p.remove(); else setTimeout(() => p.remove(), delay);
}
/* ============ REQUÊTES ============ */
function req(url, type = "text") {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: "GET", url, responseType: type, timeout: 30000,
onload: (r) => {
if (r.status === 429) { reject(new Error("HTTP 429 (limitation)")); return; }
if (r.status >= 200 && r.status < 300) resolve(r.response);
else reject(new Error(`HTTP ${r.status} : ${url}`));
},
onerror: () => reject(new Error(`Erreur réseau : ${url}`)),
ontimeout: () => reject(new Error(`Délai dépassé : ${url}`))
});
});
}
const getJSON = (url) => req(url).then((t) => JSON.parse(t));
const pageWindow = (typeof unsafeWindow !== "undefined") ? unsafeWindow : window;
async function pageFetchText(url) {
const res = await pageWindow.fetch(url, { credentials: "include" });
if (!res.ok) throw new Error(`HTTP ${res.status} : ${url}`);
return res.text();
}
async function pageFetchBuffer(url) {
const res = await pageWindow.fetch(url, { credentials: "omit" });
if (!res.ok) throw new Error(`HTTP ${res.status} : ${url}`);
return res.arrayBuffer();
}
// Chargement d'une page via iframe cachée = vraie navigation (passe l'anti-robot)
function fetchViaIframe(url) {
return new Promise((resolve, reject) => {
const fr = document.createElement("iframe");
fr.style.display = "none";
let done = false, poll = null;
const finish = (val, isErr) => {
if (done) return;
done = true;
clearTimeout(to); clearInterval(poll);
fr.remove();
isErr ? reject(val) : resolve(val);
};
const to = setTimeout(() => finish(new Error("Délai iframe dépassé"), true), 20000);
fr.onload = () => {
poll = setInterval(() => {
try {
const d = fr.contentDocument;
if (d && d.querySelector('img[src*="reader.hentai.gifts"], img[data-src*="reader.hentai.gifts"]'))
finish(d.documentElement.outerHTML, false);
} catch (e) { finish(e, true); }
}, 500);
setTimeout(() => {
try {
const d = fr.contentDocument;
if (d) finish(d.documentElement.outerHTML, false);
} catch (e) { finish(e, true); }
}, 6000);
};
fr.onerror = () => finish(new Error("iframe error"), true);
fr.src = url;
document.body.appendChild(fr);
});
}
/* ============ NHENTAI : INFOS ============ */
const getLangFromTags = (tags) => {
const t = (tags || []).find((x) => x.type === "language" && x.name !== "translated");
return t ? t.name : undefined;
};
const buildFromApi = (d, gid) => ({
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" }))
});
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 buildFromApi(d, gid);
if (d && d.error) throw new Error(d.error);
} catch (e) { log("API v2 indisponible :", e.message); }
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) { log("Ancienne API indisponible :", e.message); }
try {
const html = await req(`https://nhentai.net/g/${gid}/`);
const doc = new DOMParser().parseFromString(html, "text/html");
const script = doc.querySelector('script[data-sveltekit-fetched][data-url^="/api/v2/galleries/"]');
if (script) {
const d = JSON.parse(JSON.parse(script.textContent).body);
if (d && d.media_id && Array.isArray(d.pages)) return buildFromApi(d, gid);
}
const img0 = doc.querySelector("#thumbnail-container img");
const src0 = img0 && (img0.dataset.src || img0.src || "");
const mid = (src0.match(/\/galleries\/([0-9a-z]+)\//) || [])[1];
const thumbs = [...doc.querySelectorAll("#thumbnail-container img")];
if (mid && thumbs.length) {
const langEls = [...doc.querySelectorAll('#tags a[href^="/language/"]')];
const langEl = langEls.find((a) => !/\/translated\/?$/.test(a.getAttribute("href") || ""));
return {
mid,
title: ((doc.querySelector("#info h1") || {}).textContent || `gallery-${gid}`).trim(),
lang: langEl ? ((langEl.getAttribute("href") || "").match(/\/language\/([^/]+)/) || [])[1] : undefined,
pages: thumbs.map((img, idx) => {
const m = (img.dataset.src || img.src || "").match(/\/(\d+)t?\.([^/]+)$/);
return { i: m ? Number(m[1]) : idx + 1, ext: m ? m[2] : "jpg" };
})
};
}
} catch (e) { log("Page galerie indisponible :", e.message); }
throw new Error("Infos introuvables pour la galerie " + gid);
}
async function getGalleryWithRetry(gid) {
for (let attempt = 0; attempt < 3; attempt++) {
try { return await getGallery(gid); }
catch (e) {
const m = e && e.message ? e.message : String(e);
if (!/429|rate ?limit|too many/i.test(m)) throw e;
if (attempt < 2) { await sleep(10000); } else throw e;
}
}
}
/* ============ COMPRESSION ============ */
const ZIP_WORKER_CODE = [
'importScripts("https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js");',
'self.onmessage = function (e) {',
' var d = e.data;',
' var zip = new JSZip();',
' for (var i = 0; i < d.files.length; i++) zip.file(d.files[i].name, d.files[i].data);',
' zip.generateAsync({ type: "arraybuffer", compression: "STORE" }, function (meta) {',
' self.postMessage({ id: d.id, type: "progress", percent: meta.percent });',
' }).then(function (buf) {',
' self.postMessage({ id: d.id, type: "done", buffer: buf }, [buf]);',
' }, function (err) {',
' self.postMessage({ id: d.id, type: "error", message: String(err) });',
' });',
'};'
].join("\n");
let zipWorker = null, workerBroken = false, jobId = 0;
const zipJobs = new Map();
function getZipWorker() {
if (zipWorker) return zipWorker;
const url = URL.createObjectURL(new Blob([ZIP_WORKER_CODE], { type: "application/javascript" }));
zipWorker = new Worker(url);
zipWorker.onmessage = (e) => {
const { id, type, percent, buffer, message } = e.data;
const job = zipJobs.get(id);
if (!job) return;
if (type === "progress") job.onProgress && job.onProgress(percent);
else if (type === "done") { zipJobs.delete(id); job.resolve(buffer); }
else if (type === "error") { zipJobs.delete(id); job.reject(new Error(message)); }
};
zipWorker.onerror = () => { workerBroken = true; zipJobs.forEach((j) => j.reject(new Error("worker error"))); zipJobs.clear(); };
return zipWorker;
}
function zipInWorker(files, onProgress) {
return new Promise((resolve, reject) => {
const id = ++jobId;
zipJobs.set(id, { resolve, reject, onProgress });
try { getZipWorker().postMessage({ id, files }); } catch (e) { zipJobs.delete(id); reject(e); }
});
}
async function zipMainThread(files, onProgress) {
const zip = new JSZip();
files.forEach((f) => zip.file(f.name, f.data));
return zip.generateAsync({ type: "blob", compression: "STORE" }, (m) => onProgress && onProgress(m.percent));
}
async function compress(files, onProgress) {
if (!workerBroken) {
try {
const buffer = await zipInWorker(files, onProgress);
return new Blob([buffer], { type: "application/zip" });
} catch (e) { log("Worker indisponible :", e.message); }
}
return zipMainThread(files, onProgress);
}
/* ============ NHENTAI : TÉLÉCHARGEMENT ============ */
async function fetchPageImage(mid, page) {
for (let h = 0; h < HOSTS.length; h++) {
const url = `https://${HOSTS[h]}/galleries/${mid}/${page.i}.${page.ext}`;
try { return { data: await req(url, "arraybuffer"), ext: page.ext }; }
catch (e) {
const m = e && e.message ? e.message : "";
if (/HTTP 404/.test(m)) 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 du téléchargement de la page " + page.i);
}
async function buildGalleryBlob(g, onStatus) {
const total = g.pages.length;
const pad = String(total).length;
const results = new Array(total);
const queue = [...g.pages];
let done = 0;
const worker = async () => {
while (queue.length) {
const page = queue.shift();
const img = await fetchPageImage(g.mid, page);
results[page.i - 1] = { name: `${String(page.i).padStart(pad, "0")}.${img.ext}`, data: img.data };
done++;
onStatus(`${done}/${total}`, (100 * done) / total, "dl");
}
};
await Promise.all(Array.from({ length: THREADS }, worker));
return compress(results.filter(Boolean), (pct) => { const p = Number(pct).toFixed(0); onStatus(`Zip ${p}%`, pct, "zip"); });
}
async function trySave(filename, blob, pages, gid) {
const old = libGet(filename);
if (old) {
if (!(pages > old.p || blob.size > old.s)) return "dup-small";
const ok = await askReplace(filename, old, { p: pages, s: blob.size });
if (!ok) return "dup-skip";
saveBlob(filename, blob); libSet(filename, { p: pages, s: blob.size, g: gid });
return "saved-replace";
}
saveBlob(filename, blob); libSet(filename, { p: pages, s: blob.size, g: gid });
return "saved-new";
}
function saveBlob(filename, blob) {
const a = document.createElement("a");
a.href = URL.createObjectURL(blob);
a.download = filename;
document.body.appendChild(a);
a.click();
setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 5000);
}
function makeBtn(txt, small) {
const b = document.createElement("button");
b.className = "cbz-dl-btn" + (small ? " cbz-small" : "");
b.textContent = txt;
b.dataset.label = txt;
return b;
}
async function run(gid, btn, label) {
if (btn.dataset.busy) return;
btn.dataset.busy = "1"; btn.disabled = true;
btn.classList.remove("cbz-ok", "cbz-err");
let resetDelay = 5000;
try {
const g = await getGalleryWithRetry(gid);
showPanel(g.title);
const blob = await buildGalleryBlob(g, (txt, pct, mode) => { btn.textContent = txt; updatePanel(pct, txt, mode); });
const filename = `${sanitize(g.title) || `gallery-${g.mid}`}.cbz`;
const res = await trySave(filename, blob, g.pages.length, gid);
if (res === "saved-new" || res === "saved-replace") {
setStatus(gid, "ok");
btn.textContent = "✓ Téléchargé avec succès"; btn.classList.add("cbz-ok");
updatePanel(100, "✓ Terminé", "zip"); hidePanel(3000);
showToast("success", `✓ Téléchargé avec succès : ${filename}`);
} else if (res === "dup-skip") {
btn.textContent = "Ignoré (choix)"; updatePanel(100, "Ignoré", "zip"); hidePanel(3000);
} else {
btn.textContent = "Ignoré (déjà présent)"; updatePanel(100, "Ignoré", "zip"); hidePanel(3000);
showToast("info", `ⓘ « ${filename} » déjà présent (identique ou plus petit) — ignoré.`);
}
} catch (e) {
log(e);
const msg = e && e.message ? e.message : String(e);
btn.textContent = `✗ Erreur : ${msg}`; btn.classList.add("cbz-err");
updatePanel(100, "✗ Erreur", "err"); hidePanel(4000);
showToast("error", `✗ Échec du téléchargement : ${msg}`);
setStatus(gid, "err"); resetDelay = 8000;
} finally {
btn.disabled = false; delete btn.dataset.busy;
setTimeout(() => paintStatus(btn, gid, label), resetDelay);
}
}
function initDetailPage() {
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 label = "Download .cbz";
const btn = makeBtn(label, false);
btn.dataset.cbzDetail = "1";
box.appendChild(btn);
registerBtn(btn, gid, label);
paintStatus(btn, gid, label);
btn.addEventListener("click", () => run(gid, btn, label));
}
function initListButtons() {
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 label = "⬇ CBZ";
const btn = makeBtn(label, true);
g.appendChild(btn);
registerBtn(btn, gid, label);
paintStatus(btn, gid, label);
btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); run(gid, btn, label); });
});
}
function parseGalleries(doc, entries) {
let added = 0;
doc.querySelectorAll(".gallery").forEach((gEl) => {
const a = gEl.querySelector("a.cover") || gEl.querySelector("a");
const href = (a && a.getAttribute("href")) || "";
const gid = (href.match(/\/g\/(\d+)/) || [])[1];
if (gid && !entries.has(gid)) { entries.set(gid, { gid }); added++; }
});
return added;
}
function getMaxPage(doc) {
let maxPage = 1;
doc.querySelectorAll(".pagination a, .pagination button, .pagination span").forEach((el) => {
const href = (el.getAttribute && el.getAttribute("href")) || "";
const m = href.match(/[?&]page=(\d+)/);
if (m) maxPage = Math.max(maxPage, Number(m[1]));
const n = parseInt((el.textContent || "").trim(), 10);
if (!isNaN(n)) maxPage = Math.max(maxPage, n);
});
return maxPage;
}
async function collectGalleriesFromUrl(url) {
const entries = new Map();
let firstHtml;
try { firstHtml = await req(url); } catch (e) { return []; }
const firstDoc = new DOMParser().parseFromString(firstHtml, "text/html");
parseGalleries(firstDoc, entries);
const maxPage = Math.min(getMaxPage(firstDoc), 60);
for (let p = 2; p <= maxPage; p++) {
const sep = url.includes("?") ? "&" : "?";
let added = 0;
try {
const html = await req(`${url}${sep}page=${p}`);
added = parseGalleries(new DOMParser().parseFromString(html, "text/html"), entries);
} catch (e) { break; }
if (!added) break;
}
return [...entries.values()];
}
function askBulkChoice(count, onChoice) {
const old = document.getElementById("cbz-bulk-dialog");
if (old) old.remove();
const box = document.createElement("div");
box.id = "cbz-bulk-dialog";
box.innerHTML = '<div class="cbz-bulk-box"><div class="cbz-bulk-title">Télécharger les ' + count + ' galeries de cette liste ?</div><div class="cbz-bulk-sub">Choisis ce qui doit être téléchargé en .cbz :</div><div class="cbz-bulk-buttons"><button data-f="all">Tout télécharger</button><button data-f="english">English uniquement</button><button data-f="japanese">Japanese uniquement</button><button data-f="chinese">Chinese uniquement</button><button data-f="cancel" class="cbz-bulk-cancel">Annuler</button></div></div>';
document.body.appendChild(box);
box.addEventListener("click", (e) => {
const f = e.target && e.target.dataset ? e.target.dataset.f : null;
if (!f) return;
box.remove();
if (f !== "cancel") onChoice(f);
});
}
let bulkRunning = false;
async function bulkDownload(entries, filter) {
if (bulkRunning) { showToast("error", "Un lot est déjà en cours"); return; }
bulkRunning = true;
let done = 0, replaced = 0, skippedLang = 0, skippedDup = 0, failed = 0, firstError = "";
const totalAll = entries.length;
try {
for (let idx = 0; idx < totalAll; idx++) {
if (idx) await sleep(1000);
const entry = entries[idx];
const head = `Lot ${idx + 1}/${totalAll} — `;
showPanel(`Lot ${idx + 1}/${totalAll}`);
updatePanel(0, head + "infos…", "dl");
try {
const g = await getGalleryWithRetry(entry.gid);
if (filter !== "all" && (g.lang || "") !== filter) { skippedLang++; continue; }
const blob = await buildGalleryBlob(g, (txt, pct, mode) => updatePanel(pct, head + txt, mode));
const filename = `${sanitize(g.title) || `gallery-${g.mid}`}.cbz`;
const res = await trySave(filename, blob, g.pages.length, entry.gid);
if (res === "saved-new" || res === "saved-replace") { done++; if (res === "saved-replace") replaced++; setStatus(entry.gid, "ok"); }
else { skippedDup++; setStatus(entry.gid, "ok"); }
repaintBtn(entry.gid);
} catch (e) {
log("Lot échec :", e);
if (!firstError) firstError = e && e.message ? e.message : String(e);
setStatus(entry.gid, "err"); repaintBtn(entry.gid); failed++;
}
}
const msg = `Lot terminé : ${done} téléchargé(s), ${failed} échec(s), ${skippedLang + skippedDup} ignoré(s)`;
showToast(failed ? "error" : "success", (failed ? "✗ " : "✓ ") + msg);
updatePanel(100, msg, failed ? "err" : "zip");
showReport(`Galeries analysées : ${totalAll}\n✓ Téléchargées : ${done}${replaced ? ` (dont ${replaced} remplacement(s))` : ""}\n✗ Échecs : ${failed}\n— Ignorées (langue) : ${skippedLang}\n— Ignorées (déjà présentes) : ${skippedDup}` + (firstError ? `\n\nPremière erreur : ${firstError}` : ""));
} finally { bulkRunning = false; hidePanel(4000); }
}
async function startBulkFromUrl(url, btn) {
if (bulkRunning) { showToast("error", "Un lot est déjà en cours"); return; }
btn.disabled = true;
const oldTxt = btn.textContent;
btn.textContent = "…";
let entries = [];
try { entries = await collectGalleriesFromUrl(url); } catch (e) { log(e); }
btn.disabled = false;
btn.textContent = oldTxt;
if (!entries.length) { showToast("error", "Aucune galerie trouvée dans cette liste"); return; }
askBulkChoice(entries.length, (filter) => bulkDownload(entries, filter));
}
function initBulkButton() {
if (location.pathname.match(/^\/g\/\d+/)) return;
if (!document.querySelector(".gallery") || document.getElementById("cbz-bulk-btn")) return;
const btn = makeBtn("⬇ Tout en CBZ", false);
btn.id = "cbz-bulk-btn";
btn.addEventListener("click", () => startBulkFromUrl(location.href, btn));
const header = document.querySelector("#content h1") || document.querySelector("h1");
if (header) header.appendChild(btn);
else (document.querySelector("#content") || document.body).prepend(btn);
}
const TAG_TYPES = ["artist", "tag", "character", "parody", "group", "language", "category"];
function initTagBulkButtons() {
if (!location.pathname.match(/^\/g\/\d+/)) return;
const box = document.querySelector("#tags");
if (!box) return;
box.querySelectorAll("a").forEach((a) => {
if (a.dataset.cbzBulkTag) return;
const href = a.getAttribute("href") || "";
if (!TAG_TYPES.some((t) => href.startsWith(`/${t}/`))) return;
a.dataset.cbzBulkTag = "1";
const btn = document.createElement("button");
btn.className = "cbz-tag-btn";
btn.textContent = "⬇";
btn.title = "Télécharger toutes les galeries de ce tag en .cbz";
a.parentNode.insertBefore(btn, a.nextSibling);
btn.addEventListener("click", (e) => { e.preventDefault(); e.stopPropagation(); startBulkFromUrl(location.origin + href, btn); });
});
}
/* ================================================================
X-MANGA.ORG
================================================================ */
const xmangaNum = (label) => parseInt((label.match(/(\d+)/) || [])[1] || "0", 10);
const xmangaFilename = (title, label) => {
const t = sanitize(title), c = sanitize(label);
return IS_FIREFOX_SAFE ? `${t} - ${c}.cbz` : `${t}/${c}.cbz`;
};
function xmangaGetSeriesInfo() {
const title = (document.querySelector("h1")?.textContent || document.title.replace(/\s*[–-]\s*X-MANGA.*$/i, "")).trim();
const chapters = [...document.querySelectorAll(".listing-chapters_wrap li.wp-manga-chapter > a, li.wp-manga-chapter > a")]
.map((a) => ({ url: a.href, label: a.textContent.trim() }))
.filter((c) => /\/chapitre-\d+/.test(c.url));
return { title, chapters };
}
// Récupère le HTML d'un chapitre via 3 méthodes (fetch → iframe → GM)
async function xmangaGetChapterImages(url) {
let html = null;
// 1) fetch navigateur
try {
const t = await pageFetchText(url);
if (/reader\.hentai\.gifts/.test(t)) { html = t; log("chapitre via fetch"); }
} catch (e) { log("fetch échec :", e.message); }
// 2) iframe (vraie navigation)
if (!html) {
try {
const t = await fetchViaIframe(url);
if (/reader\.hentai\.gifts/.test(t)) { html = t; log("chapitre via iframe"); }
} catch (e) { log("iframe échec :", e.message); }
}
// 3) GM_xmlhttpRequest
if (!html) {
try {
const t = await req(url, "text");
if (/reader\.hentai\.gifts/.test(t)) { html = t; log("chapitre via GM"); }
} catch (e) { log("GM échec :", e.message); }
}
if (!html) throw new Error("Page du chapitre inaccessible : " + url);
const clean = html.replace(/\\\//g, "/");
const urls = [];
const re = /https?:\/\/reader\.hentai\.gifts\/[^"'\\\s<>]+\.(?:png|jpe?g|webp|gif)/gi;
let m;
while ((m = re.exec(clean))) { if (!urls.includes(m[0])) urls.push(m[0]); }
if (!urls.length) {
const doc = new DOMParser().parseFromString(html, "text/html");
[...doc.querySelectorAll("img")].forEach((i) => {
const u = i.getAttribute("data-src") || i.getAttribute("src") || "";
if (/reader\.hentai\.gifts\//.test(u) && !urls.includes(u)) urls.push(u);
});
}
return urls;
}
async function downloadUrlsToBlob(urls, onStatus) {
const results = new Array(urls.length);
const queue = urls.map((u, i) => ({ u, i }));
let done = 0;
const worker = async () => {
while (queue.length) {
const { u, i } = queue.shift();
let data = null;
for (let e = 0; e < 4 && !data; e++) {
try { data = await req(u, "arraybuffer"); }
catch (err) {
try { data = await pageFetchBuffer(u); } catch (e2) { await sleep(600); }
}
}
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[i] = { name: `${String(i + 1).padStart(3, "0")}.${ext}`, data };
done++;
onStatus(`${done}/${urls.length}`, (100 * done) / urls.length, "dl");
}
};
await Promise.all(Array.from({ length: THREADS }, worker));
return compress(results.filter(Boolean), (pct) => { const p = Number(pct).toFixed(0); onStatus(`Zip ${p}%`, pct, "zip"); });
}
async function xmangaDownloadChapter(url, fname, btn) {
if (btn.dataset.busy) return;
btn.dataset.busy = "1"; btn.disabled = true;
try {
showPanel(fname);
const urls = (url === location.href) ? xmangaLiveImages() : await xmangaGetChapterImages(url);
if (!urls.length) throw new Error("Aucune image trouvée");
const blob = await downloadUrlsToBlob(urls, (txt, pct, mode) => { btn.textContent = txt; updatePanel(pct, txt, mode); });
saveBlob(fname, blob);
libSet(fname, { p: urls.length, s: blob.size, g: url });
btn.textContent = "✓ Téléchargé"; btn.classList.add("cbz-ok");
updatePanel(100, "✓ Terminé", "zip"); hidePanel(3000);
showToast("success", `✓ Téléchargé avec succès : ${fname}`);
} catch (e) {
log(e);
btn.textContent = "✗ Erreur"; btn.classList.add("cbz-err");
showToast("error", `✗ Échec : ${e && e.message ? e.message : e}`);
} finally {
btn.disabled = false; delete btn.dataset.busy;
setTimeout(() => { btn.classList.remove("cbz-ok", "cbz-err"); btn.textContent = btn.dataset.label || "⬇"; }, 5000);
}
}
function xmangaLiveImages() {
return [...document.querySelectorAll("img")]
.map((i) => i.dataset.src || i.src)
.filter((u) => /reader\.hentai\.gifts\//.test(u))
.filter((u, i, a) => a.indexOf(u) === i);
}
async function xmangaBulk(info, btn) {
if (bulkRunning) { showToast("error", "Un lot est déjà en cours"); return; }
bulkRunning = true;
const chapters = [...info.chapters].sort((a, b) => xmangaNum(a.label) - xmangaNum(b.label));
let done = 0, skipped = 0, failed = 0, firstError = "";
try {
for (let i = 0; i < chapters.length; i++) {
if (i) await sleep(600);
const ch = chapters[i];
const fname = xmangaFilename(info.title, ch.label);
const head = `Lot ${i + 1}/${chapters.length} — `;
showPanel(info.title);
updatePanel(0, head + ch.label, "dl");
if (libGet(fname)) { skipped++; continue; }
try {
const urls = await xmangaGetChapterImages(ch.url);
if (!urls.length) throw new Error("Aucune image");
const blob = await downloadUrlsToBlob(urls, (txt, pct, mode) => updatePanel(pct, head + txt, mode));
saveBlob(fname, blob);
libSet(fname, { p: urls.length, s: blob.size, g: ch.url });
done++;
} catch (e) {
log("Chapitre échec :", ch.label, e);
if (!firstError) firstError = e && e.message ? e.message : String(e);
failed++;
}
}
const msg = `Lot terminé : ${done} téléchargé(s), ${skipped} déjà présent(s), ${failed} échec(s)`;
showToast(failed ? "error" : "success", (failed ? "✗ " : "✓ ") + msg);
showReport(`Chapitres analysés : ${chapters.length}\n✓ Téléchargés : ${done}\n— Déjà présents : ${skipped}\n✗ Échecs : ${failed}` + (firstError ? `\n\nPremière erreur : ${firstError}` : ""));
} finally { bulkRunning = false; hidePanel(4000); }
}
function xmangaInitSeriesPage() {
const titleEl = document.querySelector("h1");
if (!titleEl) return;
let btn = document.getElementById("cbz-bulk-btn");
if (!btn) {
btn = makeBtn("⬇ Tous les chapitres", false);
btn.id = "cbz-bulk-btn";
titleEl.insertAdjacentElement("afterend", btn);
btn.addEventListener("click", () => {
const info = xmangaGetSeriesInfo();
if (!info.chapters.length) { showToast("error", "Liste des chapitres introuvable"); return; }
xmangaBulk(info, btn);
});
}
const info = xmangaGetSeriesInfo();
if (info.chapters.length) {
const missing = info.chapters.filter((c) => !libGet(xmangaFilename(info.title, c.label))).length;
btn.textContent = `⬇ Tous les chapitres (${missing} manquants / ${info.chapters.length})`;
}
document.querySelectorAll(".listing-chapters_wrap li.wp-manga-chapter, li.wp-manga-chapter").forEach((li) => {
const a = li.querySelector("a[href*='/chapitre-']");
if (!a || li.dataset.cbzInit) return;
li.dataset.cbzInit = "1";
const label = a.textContent.trim();
const fname = xmangaFilename(info.title || "serie", label);
const b = document.createElement("button");
b.className = "cbz-chap-btn" + (libGet(fname) ? " cbz-ok" : "");
b.textContent = libGet(fname) ? "✓" : "⬇";
b.dataset.label = b.textContent;
b.title = fname;
a.parentNode.insertBefore(b, a.nextSibling);
b.addEventListener("click", (e) => {
e.preventDefault(); e.stopPropagation();
xmangaDownloadChapter(a.href, fname, b).then(() => { if (libGet(fname)) { b.textContent = "✓"; b.classList.add("cbz-ok"); } });
});
});
}
function xmangaInitChapterPage() {
const m = location.pathname.match(/\/manga\/([^/]+)\/(chapitre-\d+)/);
if (!m) return;
const slug = m[1];
const chapLabel = "Chapitre " + m[2].replace("chapitre-", "");
let title = (document.querySelector("h1")?.textContent || "").trim();
if (!title || /chapitre/i.test(title)) title = slug.replace(/-/g, " ").replace(/\b\w/g, (c) => c.toUpperCase());
const fname = xmangaFilename(title, chapLabel);
if (document.getElementById("cbz-chap-btn")) return;
const btn = makeBtn("⬇ Ce chapitre", false);
btn.id = "cbz-chap-btn";
(document.querySelector("#main-container") || document.body).prepend(btn);
btn.addEventListener("click", () => xmangaDownloadChapter(location.href, fname, btn));
}
function xmangaInit() {
if (/\/chapitre-\d+/.test(location.pathname)) { xmangaInitChapterPage(); return; }
xmangaInitSeriesPage();
let timer = null;
new MutationObserver(() => {
clearTimeout(timer);
timer = setTimeout(xmangaInitSeriesPage, 300);
}).observe(document.body, { childList: true, subtree: true });
}
/* ============ INIT ============ */
if (IS_NHENTAI) {
initDetailPage();
initListButtons();
initBulkButton();
initTagBulkButtons();
let timer = null;
new MutationObserver(() => {
clearTimeout(timer);
timer = setTimeout(() => { initListButtons(); initBulkButton(); initTagBulkButtons(); }, 300);
}).observe(document.body, { childList: true, subtree: true });
} else if (IS_XMANGA) {
xmangaInit();
}
})();