CBZ Downloader

Télécharge les galeries nhentai et les séries x-manga.org en .cbz

Você precisará instalar uma extensão como Tampermonkey, Greasemonkey ou Violentmonkey para instalar este script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

Você precisará instalar uma extensão como Tampermonkey ou Violentmonkey para instalar este script.

Você precisará instalar uma extensão como Tampermonkey ou Userscripts para instalar este script.

Você precisará instalar uma extensão como o Tampermonkey para instalar este script.

Você precisará instalar um gerenciador de scripts de usuário para instalar este script.

(Eu já tenho um gerenciador de scripts de usuário, me deixe instalá-lo!)

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar uma extensão como o Stylus para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

Você precisará instalar um gerenciador de estilos de usuário para instalar este estilo.

(Eu já possuo um gerenciador de estilos de usuário, me deixar fazer a instalação!)

// ==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();
  }
})();