Télécharge nhentai.net, x-manga.org et ortegascans.fr en .cbz avec mode auto anti-Cloudflare
// ==UserScript==
// @name CBZ Downloader (Multi-site)
// @namespace local.cbz.downloader
// @version 0.3.3
// @description Télécharge nhentai.net, x-manga.org et ortegascans.fr en .cbz avec mode auto anti-Cloudflare
// @description:fr Télécharge nhentai.net, x-manga.org et ortegascans.fr en .cbz avec mode auto anti-Cloudflare
// @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/*
// @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 x-manga.net
// @connect reader.hentai.gifts
// @connect *
// @run-at document-idle
// @noframes
// ==/UserScript==
(function () {
"use strict";
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 IS_MANGA = IS_XMANGA || IS_ORTEGA;
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 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}
.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-tag-btn{border:0;border-radius:3px;background:#ed2553;color:#fff;font-weight:700;cursor:pointer;font-size:9px;padding:1px 5px;margin-left:4px;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:240px;background:rgba(0,0,0,.85);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}
#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-bulk-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{background:#1c1c1c;color:#fff;border-radius:8px;padding:18px 22px;max-width:430px;width:calc(100% - 40px)}
#cbz-bulk-dialog .cbz-bulk-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-bulk-dialog .cbz-bulk-buttons{display:flex;flex-direction:column;gap:8px}
#cbz-bulk-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-stop{position:fixed;bottom:20px;left:20px;z-index:2147483647;padding:12px 20px;background:#c62828;color:#fff;border:0;border-radius:6px;font-weight:700;cursor:pointer;font-size:14px}
#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}
`);
const STATUS_KEY = "cbz_dl_status";
let statusMap = {};
try { statusMap = JSON.parse(GM_getValue(STATUS_KEY,"{}")) || {}; } catch(e) {}
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) {}
const saveLibrary = () => GM_setValue(LIB_KEY, JSON.stringify(library));
const libGet = n => library[n];
const libSet = (n,e) => { library[n] = e; saveLibrary(); };
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);
}
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</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").onclick = () => box.remove();
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(p, s, mode) {
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;
}
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));
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(m){',
' self.postMessage({id:d.id,type:"progress",percent:m.percent});',
' }).then(function(b){',
' self.postMessage({id:d.id,type:"done",buffer:b},[b]);',
' },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 u = URL.createObjectURL(new Blob([ZIP_WORKER_CODE], { type: "application/javascript" }));
zipWorker = new Worker(u);
zipWorker.onmessage = e => {
const { id, type, percent, buffer, message } = e.data;
const j = zipJobs.get(id);
if (!j) return;
if (type === "progress") j.onProgress && j.onProgress(percent);
else if (type === "done") { zipJobs.delete(id); j.resolve(buffer); }
else { zipJobs.delete(id); j.reject(new Error(message)); }
};
zipWorker.onerror = () => { workerBroken = true; zipJobs.forEach(j => j.reject(new Error("worker"))); zipJobs.clear(); };
return zipWorker;
}
async function compress(files, onProgress) {
if (!workerBroken) {
try {
const buf = await new Promise((res, rej) => {
const id = ++jobId;
zipJobs.set(id, { resolve: res, reject: rej, onProgress });
getZipWorker().postMessage({ id, files });
});
return new Blob([buf], { type: "application/zip" });
} catch (e) { log("Worker HS:", e.message); }
}
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));
}
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, small) {
const b = document.createElement("button");
b.className = "cbz-dl-btn" + (small ? " cbz-small" : "");
b.textContent = txt;
return b;
}
/* ================= 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) { log("API v2:", 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:", e.message); }
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 " + Number(p).toFixed(0) + "%", p, "zip"));
}
const btnRegistry = [];
function registerBtn(btn, gid, label) { btnRegistry.push({ btn, gid, label }); }
function paintStatus(btn, gid, label) {
const st = statusMap[gid], 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;
}
function repaintBtn(gid) { btnRegistry.forEach(r => { if (String(r.gid) === String(gid)) paintStatus(r.btn, r.gid, r.label); }); }
async function trySave(filename, blob, pages, gid) {
const old = libGet(filename);
if (old && !(pages > old.p || blob.size > old.s)) return "dup-small";
saveBlob(filename, blob);
libSet(filename, { p: pages, s: blob.size, g: gid });
return old ? "saved-replace" : "saved-new";
}
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";
const res = await trySave(filename, blob, g.pages.length, gid);
if (res === "saved-new" || res === "saved-replace") {
setStatus(gid, "ok"); btn.textContent = "✓ Terminé"; btn.classList.add("cbz-ok");
updatePanel(100, "✓ Terminé", "zip"); hidePanel(3000);
showToast("success", "✓ " + filename);
} else {
btn.textContent = "Ignoré"; updatePanel(100, "Ignoré", "zip"); hidePanel(3000);
showToast("info", "ⓘ Déjà présent (identique ou plus petit)");
}
} catch (e) {
btn.textContent = "✗ " + e.message; btn.classList.add("cbz-err");
updatePanel(100, "✗ Erreur"); hidePanel(4000);
showToast("error", "✗ " + e.message); setStatus(gid, "err");
} 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);
registerBtn(btn, gid, "Download .cbz");
paintStatus(btn, gid, "Download .cbz");
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", true);
g.appendChild(btn);
registerBtn(btn, gid, "⬇ CBZ");
paintStatus(btn, gid, "⬇ CBZ");
btn.onclick = e => { e.preventDefault(); e.stopPropagation(); runNhentai(gid, btn, "⬇ CBZ"); };
});
}
function parseGalleries(doc, entries) {
let added = 0;
doc.querySelectorAll(".gallery").forEach(gEl => {
const a = gEl.querySelector("a.cover") || gEl.querySelector("a");
const gid = ((a && a.getAttribute("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 html;
try { html = await req(url); } catch (e) { return []; }
const doc = new DOMParser().parseFromString(html, "text/html");
parseGalleries(doc, entries);
const maxPage = Math.min(getMaxPage(doc), 60);
for (let p = 2; p <= maxPage; p++) {
const sep = url.includes("?") ? "&" : "?";
let added = 0;
try {
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 ' + count + ' galeries ?</div>' +
'<div class="cbz-bulk-sub">Choix :</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.onclick = 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", "Lot déjà en cours"); return; }
bulkRunning = true;
let done = 0, replaced = 0, skippedLang = 0, skippedDup = 0, failed = 0, firstError = "";
try {
for (let i = 0; i < entries.length; i++) {
if (i) await sleep(1000);
const entry = entries[i];
const head = "Lot " + (i+1) + "/" + entries.length + " — ";
showPanel("Lot " + (i+1) + "/" + entries.length);
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, (t, p, m) => updatePanel(p, head + t, m));
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) {
if (!firstError) firstError = e.message;
setStatus(entry.gid, "err"); repaintBtn(entry.gid); failed++;
}
}
showReport("Galeries analysées : " + entries.length + "\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", "Lot déjà en cours"); return; }
btn.disabled = true;
const old = btn.textContent;
btn.textContent = "…";
let entries = [];
try { entries = await collectGalleriesFromUrl(url); } catch (e) {}
btn.disabled = false;
btn.textContent = old;
if (!entries.length) { showToast("error", "Aucune galerie trouvée"); return; }
askBulkChoice(entries.length, f => bulkDownload(entries, f));
}
function initNhentaiBulkButton() {
if (location.pathname.match(/^\/g\/\d+/)) return;
if (!document.querySelector(".gallery") || document.getElementById("cbz-bulk-btn")) return;
const btn = makeBtn("⬇ Tout en CBZ");
btn.id = "cbz-bulk-btn";
btn.onclick = () => startBulkFromUrl(location.href, btn);
const h = document.querySelector("#content h1") || document.querySelector("h1");
if (h) h.appendChild(btn);
else (document.querySelector("#content") || document.body).prepend(btn);
}
const TAG_TYPES = ["artist","tag","character","parody","group","language","category"];
function initNhentaiTagButtons() {
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";
a.parentNode.insertBefore(btn, a.nextSibling);
btn.onclick = e => { e.preventDefault(); e.stopPropagation(); startBulkFromUrl(location.origin + href, btn); };
});
}
function initNhentai() {
initNhentaiDetail();
initNhentaiList();
initNhentaiBulkButton();
initNhentaiTagButtons();
let timer = null;
new MutationObserver(() => {
clearTimeout(timer);
timer = setTimeout(() => {
initNhentaiDetail(); initNhentaiList(); initNhentaiBulkButton(); initNhentaiTagButtons();
}, 200);
}).observe(document.body, { childList: true, subtree: true });
}
/* ================= MANGA (x-manga + ortega) ================= */
const labelFromUrl = u => {
const m = u.match(/\/(chapitre|chapter|capitulo)[\/-]?(\d+)/i);
return m ? "Chapitre " + m[2] : u;
};
function getMangaSeriesInfo() {
const title = (document.querySelector("h1")?.textContent || document.querySelector(".entry-title")?.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()] };
}
// Attend que la liste des chapitres apparaisse dans le DOM (pour les sites Next.js/React)
async function waitForChapters(timeout = 10000) {
const start = Date.now();
while (Date.now() - start < timeout) {
const info = getMangaSeriesInfo();
if (info.chapters.length > 0) return info;
await sleep(500);
}
return getMangaSeriesInfo();
}
const chapNum = l => parseInt((l.match(/(\d+)/) || [])[1] || "0", 10);
const chapFile = (t, l) => sanitize(t + " - " + l) + ".cbz";
function xmangaGrab() {
return [...new Set([...document.querySelectorAll("img")].map(i => i.dataset.src || i.src).filter(u => /reader\.hentai\.gifts\//.test(u)))];
}
function ortegaGrab() {
const sels = ["#readerarea img",".reading-content img",".container-chapter-reader img",".chapter-images img","main img","article img"];
let imgs = [];
for (const s of sels) {
const f = [...document.querySelectorAll(s)];
if (f.length) { imgs = f; break; }
}
if (!imgs.length) imgs = [...document.querySelectorAll("img")];
const srcOf = i => i.dataset.src || i.dataset.lazySrc || i.src;
const cand = imgs.map(srcOf)
.filter(u => u && /^https?:/.test(u))
.filter(u => !/logo|icon|avatar|gravatar|banner|emoji|button|favicon|\/ads?\//i.test(u));
const big = imgs.filter(i => (i.naturalWidth || i.clientWidth || 0) > 150)
.map(srcOf)
.filter(u => u && /^https?:/.test(u));
return [...new Set(big.length ? big : cand)];
}
const currentGrab = () => IS_XMANGA ? xmangaGrab : ortegaGrab;
function waitForLiveImages(grab, timeout = 25000) {
return new Promise(res => {
const start = Date.now();
const check = () => {
const u = grab();
if (u.length) return res(u);
if (Date.now() - start > timeout) return res([]);
window.scrollTo(0, document.body.scrollHeight);
setTimeout(check, 600);
};
check();
});
}
async function downloadUrls(urls, onStatus) {
const results = new Array(urls.length), queue = urls.map((u, i) => ({ u, i }));
let done = 0;
const worker = async () => {
while (queue.length) {
const { u, i } = queue.shift();
let data = null;
try { data = await req(u, "arraybuffer"); } catch (e) {}
if (!data) { try { data = await req(u, "arraybuffer", { Referer: location.href, Origin: 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[i] = { name: String(i + 1).padStart(3, "0") + "." + ext, data };
done++;
onStatus(done + "/" + urls.length, (100 * done) / urls.length);
await sleep(150);
}
};
await Promise.all(Array.from({ length: 2 }, worker));
return compress(results.filter(Boolean), p => onStatus("Zip " + Number(p).toFixed(0) + "%", p, "zip"));
}
function stopAuto() {
GM_setValue("auto_mode", false);
GM_setValue("auto_queue", "[]");
const s = document.getElementById("cbz-stop");
if (s) s.remove();
}
async function autoStep() {
if (!GM_getValue("auto_mode", false)) return;
const 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 current = queue[0];
showPanel("Auto : " + title);
updatePanel(0, "Lecture des images…");
const urls = await waitForLiveImages(currentGrab());
const label = current.label || labelFromUrl(current.url);
if (urls.length) {
try {
const blob = await downloadUrls(urls, (t, p) => updatePanel(p, label + " — " + t));
const fname = chapFile(title, label);
saveBlob(fname, blob);
libSet(fname, { p: urls.length, s: blob.size, g: current.url });
showToast("success", "✓ " + fname);
} catch (e) {
log("échec", label, e);
showToast("error", "✗ " + label + " : " + e.message);
}
} else {
showToast("warning", "Images introuvables : " + label);
}
queue.shift();
GM_setValue("auto_queue", JSON.stringify(queue));
if (queue.length) {
updatePanel(100, "→ suivant…");
await sleep(1500);
location.href = queue[0].url;
} else {
stopAuto();
hidePanel();
showToast("success", "Lot terminé");
}
}
function startAuto(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é"); return; }
GM_setValue("auto_mode", true);
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 + " chapitres)");
location.href = remaining[0].url;
}
function mangaInitSeries() {
const titleEl = document.querySelector("h1") || document.querySelector(".entry-title");
if (!titleEl) return;
if (!document.getElementById("cbz-bulk-btn")) {
const b = makeBtn("⬇ Tous les chapitres (auto)");
b.id = "cbz-bulk-btn";
titleEl.insertAdjacentElement("afterend", b);
b.onclick = async () => {
showToast("info", "Chargement de la liste des chapitres…");
const info = await waitForChapters(10000);
if (!info.chapters.length) {
showToast("error", "Liste des chapitres introuvable après 10s. Recharge la page et réessaie.");
return;
}
startAuto(info);
};
}
const info = getMangaSeriesInfo();
if (info.chapters.length) {
const missing = info.chapters.filter(c => !libGet(chapFile(info.title, c.label))).length;
document.getElementById("cbz-bulk-btn").textContent = "⬇ Tous les chapitres (auto) (" + missing + " manquants / " + info.chapters.length + ")";
document.querySelectorAll("a").forEach(a => {
if (!CHAP_RE.test(a.href) || a.dataset.cbzInit) return;
a.dataset.cbzInit = "1";
const label = a.textContent.trim() || labelFromUrl(a.href);
const fname = chapFile(info.title || "série", label);
const b = document.createElement("button");
b.className = "cbz-chap-btn" + (libGet(fname) ? " cbz-ok" : "");
b.textContent = libGet(fname) ? "✓" : "⬇";
a.parentNode.insertBefore(b, a.nextSibling);
});
}
}
let autoStarted = false;
function mangaInitChapter() {
if (GM_getValue("auto_mode", false)) {
if (!autoStarted) { autoStarted = true; autoStep(); }
return;
}
if (!CHAP_RE.test(location.href)) return;
if (document.getElementById("cbz-chap-btn")) return;
let title = (document.querySelector("h1")?.textContent || "").trim();
const chapLabel = labelFromUrl(location.href);
if (!title || CHAP_RE.test(title)) title = document.title.split(/[|–-]/)[0].trim();
const fname = chapFile(title, chapLabel);
const b = makeBtn("⬇ Ce chapitre");
b.id = "cbz-chap-btn";
(document.querySelector("#main-container") || document.body).prepend(b);
b.onclick = async () => {
b.disabled = true;
b.textContent = "…";
const urls = await waitForLiveImages(currentGrab());
if (!urls.length) { showToast("error", "Aucune image"); b.disabled = false; b.textContent = "⬇ Ce chapitre"; return; }
try {
const blob = await downloadUrls(urls, (t, p) => { b.textContent = t; updatePanel(p, t); });
saveBlob(fname, blob);
libSet(fname, { p: urls.length, s: blob.size, g: location.href });
b.textContent = "✓";
b.classList.add("cbz-ok");
showToast("success", "✓ " + fname);
} catch (e) {
b.textContent = "✗";
showToast("error", e.message);
}
hidePanel(2000);
};
}
/* ================= INIT ================= */
if (IS_NHENTAI) {
initNhentai();
} else if (IS_MANGA) {
const initM = () => {
if (CHAP_RE.test(location.href)) mangaInitChapter();
else mangaInitSeries();
};
initM();
let timer = null;
new MutationObserver(() => {
clearTimeout(timer);
timer = setTimeout(initM, 200);
}).observe(document.body, { childList: true, subtree: true });
setInterval(initM, 2000);
}
})();