CBZ Downloader (Multi-site)

Télécharge nhentai.net, x-manga.org et ortegascans.fr en .cbz

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         CBZ Downloader (Multi-site)
// @namespace    local.cbz.downloader
// @version      0.4.8
// @description  Télécharge nhentai.net, x-manga.org et ortegascans.fr en .cbz
// @description:fr  Télécharge nhentai.net, x-manga.org et ortegascans.fr en .cbz
// @license      MIT
// @author       You
// @match        https://nhentai.net/*
// @match        https://x-manga.org/*
// @match        https://x-manga.net/*
// @match        https://ortegascans.fr/*
// @match        https://www.ortegascans.fr/*
// @match        https://*.ortegascans.fr/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        unsafeWindow
// @connect      nhentai.net
// @connect      i.nhentai.net
// @connect      i1.nhentai.net
// @connect      i2.nhentai.net
// @connect      i3.nhentai.net
// @connect      i4.nhentai.net
// @connect      i5.nhentai.net
// @connect      i6.nhentai.net
// @connect      i7.nhentai.net
// @connect      x-manga.org
// @connect      x-manga.net
// @connect      reader.hentai.gifts
// @connect      ortegascans.fr
// @connect      *
// @run-at       document-idle
// @noframes
// ==/UserScript==

(function () {
  "use strict";

  if (window.__cbzLoaded) { console.log("[CBZ] déjà chargé, 2e copie ignorée"); return; }
  window.__cbzLoaded = true;

  const IS_NHENTAI = /nhentai\./.test(location.hostname);
  const IS_XMANGA = /x-manga\.(org|net)/.test(location.hostname);
  const IS_ORTEGA = /ortega/i.test(location.hostname);
  const CHAP_RE = /\/(chapitre|chapter|capitulo)[\/-]?\d+/i;
  const HOSTS = ["i.nhentai.net","i1.nhentai.net","i2.nhentai.net","i3.nhentai.net","i4.nhentai.net","i5.nhentai.net","i6.nhentai.net","i7.nhentai.net"];
  const EXT = { j:"jpg", p:"png", g:"gif", w:"webp" };
  const sleep = ms => new Promise(r => setTimeout(r, ms));
  const sanitize = s => s.replace(/[/\\:*?"<>|]/g,"").replace(/\s+/g," ").trim();
  const normUrl = u => (u || "").replace(/\/+$/,"").toLowerCase();
  const log = (...a) => console.log("[CBZ]", ...a);

  GM_addStyle(`
    .cbz-dl-btn{margin:4px;padding:6px 14px;border:0;border-radius:4px;background:#ed2553;color:#fff;font-weight:700;cursor:pointer;font-size:13px;white-space:nowrap;display:inline-block}
    .cbz-dl-btn:disabled{opacity:.75;cursor:wait}
    .cbz-dl-btn.cbz-ok{background:#2e7d32}
    .cbz-dl-btn.cbz-err{background:#c62828}
    .cbz-dl-btn.cbz-small{position:absolute;top:2px;right:2px;z-index:5;margin:0;padding:3px 7px;font-size:11px;border-radius:3px;white-space:nowrap}
    #cbz-bulk-btn{margin:6px 10px;vertical-align:middle;flex-shrink:0;white-space:nowrap;width:auto;min-width:150px}
    #cbz-bulk-btn.cbz-float{position:fixed;bottom:90px;right:20px;z-index:2147483646;margin:0;box-shadow:0 4px 12px rgba(0,0,0,.5)}
    #cbz-chap-btn{margin:10px;padding:8px 16px;font-size:14px}
    #cbz-manage-btn{position:fixed;bottom:140px;right:20px;z-index:2147483646;margin:0;box-shadow:0 4px 12px rgba(0,0,0,.5);background:#37474f}
    #cbz-panel{position:fixed;top:20vh;right:0;z-index:2147483647;width:280px;background:rgba(0,0,0,.9);color:#fff;font-size:12px;border-radius:6px 0 0 6px;padding:10px 12px;font-family:sans-serif}
    #cbz-panel .cbz-title{white-space:nowrap;overflow:hidden;text-overflow:ellipsis;margin-bottom:6px;font-weight:700}
    #cbz-panel .cbz-bar{height:10px;background:#333;border-radius:5px;overflow:hidden}
    #cbz-panel .cbz-bar-fill{height:100%;width:0%;background:#ed2553;transition:width .2s}
    #cbz-panel .cbz-bar-fill.zip{background:#66bb6a}
    #cbz-panel .cbz-status{margin-top:4px;text-align:right;font-size:11px}
    #cbz-panel .cbz-detail{margin-top:6px;font-size:11px;opacity:.8}
    #cbz-toasts{position:fixed;right:16px;bottom:16px;z-index:2147483647;display:flex;flex-direction:column;gap:8px;max-width:340px}
    .cbz-toast{padding:10px 14px;border-radius:6px;color:#fff;font-size:13px;font-family:sans-serif;box-shadow:0 2px 10px rgba(0,0,0,.5)}
    .cbz-toast--success{background:#2e7d32}
    .cbz-toast--error{background:#c62828}
    .cbz-toast--info{background:#37474f}
    .cbz-toast--warning{background:#f59e0b}
    #cbz-stop{position:fixed;bottom:20px;left:20px;z-index:2147483647;padding:10px 18px;background:#c62828;color:#fff;border:0;border-radius:6px;font-weight:700;cursor:pointer;font-size:13px}
    #cbz-lib-dialog{position:fixed;inset:0;z-index:2147483647;background:rgba(0,0,0,.6);display:flex;align-items:center;justify-content:center;font-family:sans-serif}
    #cbz-lib-dialog .cbz-lib-box{background:#1c1c1c;color:#fff;border-radius:8px;padding:16px 20px;max-width:520px;width:calc(100% - 40px);max-height:80vh;display:flex;flex-direction:column}
    #cbz-lib-dialog .cbz-lib-title{font-weight:700;margin-bottom:8px}
    #cbz-lib-dialog .cbz-lib-list{overflow:auto;background:#111;border-radius:4px;padding:8px;flex:1;min-height:120px}
    #cbz-lib-dialog .cbz-lib-row{display:flex;align-items:center;gap:8px;padding:4px 2px;border-bottom:1px solid #222;font-size:12px}
    #cbz-lib-dialog .cbz-lib-name{flex:1;word-break:break-all}
    #cbz-lib-dialog .cbz-lib-del{background:#c62828;border:0;color:#fff;border-radius:3px;padding:2px 8px;cursor:pointer;font-size:11px}
    #cbz-lib-dialog .cbz-lib-actions{display:flex;gap:8px;margin-top:10px}
    #cbz-lib-dialog .cbz-lib-actions button{border:0;border-radius:4px;padding:8px 12px;font-weight:700;cursor:pointer;color:#fff}
    #cbz-lib-clear{background:#c62828}
    #cbz-lib-close{background:#555}
  `);

  const LIB_KEY = "cbz_library";
  let library = {};
  try { library = JSON.parse(GM_getValue(LIB_KEY,"{}")) || {}; } catch(e) {}
  const saveLibrary = () => GM_setValue(LIB_KEY, JSON.stringify(library));
  const libGet = n => library[n];
  const libSet = (n,e) => { library[n] = e; saveLibrary(); updateManageCount(); };

  function updateManageCount() {
    const b = document.getElementById("cbz-manage-btn");
    if (b) b.textContent = "🗑 CBZ (" + Object.keys(library).length + ")";
  }

  let toastBox = null;
  function showToast(type, text, timeout) {
    if (!toastBox) { toastBox = document.createElement("div"); toastBox.id = "cbz-toasts"; document.body.appendChild(toastBox); }
    const t = document.createElement("div");
    t.className = "cbz-toast cbz-toast--" + type;
    t.textContent = text;
    toastBox.appendChild(t);
    setTimeout(() => t.remove(), timeout || 5000);
  }

  let panel = null;
  function showPanel(title) {
    hidePanel(0);
    panel = document.createElement("div");
    panel.id = "cbz-panel";
    panel.innerHTML = '<div class="cbz-title"></div><div class="cbz-bar"><div class="cbz-bar-fill"></div></div><div class="cbz-status"></div><div class="cbz-detail"></div>';
    panel.querySelector(".cbz-title").textContent = title;
    document.body.appendChild(panel);
  }
  function updatePanel(p, s, mode, detail) {
    if (!panel) return;
    const f = panel.querySelector(".cbz-bar-fill");
    f.style.width = p + "%";
    f.classList.toggle("zip", mode === "zip");
    panel.querySelector(".cbz-status").textContent = s;
    if (detail !== undefined) panel.querySelector(".cbz-detail").textContent = detail;
  }
  function hidePanel(d = 0) {
    if (!panel) return;
    const p = panel; panel = null;
    if (d <= 0) p.remove(); else setTimeout(() => p.remove(), d);
  }

  function req(url, type = "text", headers = {}) {
    return new Promise((res, rej) => {
      GM_xmlhttpRequest({
        method: "GET", url: url, responseType: type, timeout: 30000, headers: headers,
        onload: r => {
          if (r.status === 429) rej(new Error("HTTP 429"));
          else if (r.status >= 200 && r.status < 300) res(r.response);
          else rej(new Error("HTTP " + r.status + " : " + url));
        },
        onerror: () => rej(new Error("Erreur réseau : " + url)),
        ontimeout: () => rej(new Error("Timeout : " + url))
      });
    });
  }
  const getJSON = u => req(u).then(t => JSON.parse(t));

  /* ===== ZIP asynchrone ===== */
  let CRC_TABLE = null;
  function getCrcTable() {
    if (CRC_TABLE) return CRC_TABLE;
    const T = new Uint32Array(256);
    for (let n = 0; n < 256; n++) { let c = n; for (let k = 0; k < 8; k++) c = (c & 1) ? (0xEDB88320 ^ (c >>> 1)) : (c >>> 1); T[n] = c; }
    CRC_TABLE = T; return T;
  }
  function crc32(u8) {
    const T = getCrcTable();
    let c = 0xFFFFFFFF;
    for (let i = 0; i < u8.length; i++) c = T[(c ^ u8[i]) & 255] ^ (c >>> 8);
    return (c ^ 0xFFFFFFFF) >>> 0;
  }
  const nameBytes = name => new TextEncoder().encode(name);

  async function compress(files, onProgress) {
    const parts = [], central = [];
    let offset = 0;
    for (let i = 0; i < files.length; i++) {
      const f = files[i];
      const u8 = new Uint8Array(f.data);
      const nb = nameBytes(f.name);
      const crc = crc32(u8);
      const lh = new DataView(new ArrayBuffer(30));
      lh.setUint32(0,0x04034b50,true); lh.setUint16(4,20,true); lh.setUint16(6,0,true); lh.setUint16(8,0,true);
      lh.setUint16(10,0,true); lh.setUint16(12,0,true); lh.setUint32(14,crc,true);
      lh.setUint32(18,u8.length,true); lh.setUint32(22,u8.length,true);
      lh.setUint16(26,nb.length,true); lh.setUint16(28,0,true);
      parts.push(new Uint8Array(lh.buffer), nb, u8);
      const ch = new DataView(new ArrayBuffer(46));
      ch.setUint32(0,0x02014b50,true); ch.setUint16(4,20,true); ch.setUint16(6,20,true); ch.setUint16(8,0,true);
      ch.setUint16(10,0,true); ch.setUint16(12,0,true); ch.setUint16(14,0,true); ch.setUint32(16,crc,true);
      ch.setUint32(20,u8.length,true); ch.setUint32(24,u8.length,true);
      ch.setUint16(28,nb.length,true); ch.setUint16(30,0,true); ch.setUint16(32,0,true);
      ch.setUint16(34,0,true); ch.setUint16(36,0,true); ch.setUint32(38,0,true); ch.setUint32(42,offset,true);
      central.push(new Uint8Array(ch.buffer), nb);
      offset += 30 + nb.length + u8.length;
      if (i % 3 === 0) { if (onProgress) onProgress(Math.round((i / files.length) * 100)); await sleep(0); }
    }
    let centralSize = 0; for (const c of central) centralSize += c.length;
    const eocd = new DataView(new ArrayBuffer(22));
    eocd.setUint32(0,0x06054b50,true); eocd.setUint16(4,0,true); eocd.setUint16(6,0,true);
    eocd.setUint16(8,files.length,true); eocd.setUint16(10,files.length,true);
    eocd.setUint32(12,centralSize,true); eocd.setUint32(16,offset,true); eocd.setUint16(20,0,true);
    parts.push(...central, new Uint8Array(eocd.buffer));
    if (onProgress) onProgress(100);
    return new Blob(parts, { type: "application/zip" });
  }

  function saveBlob(name, blob) {
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = name;
    document.body.appendChild(a);
    a.click();
    setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 5000);
  }
  function makeBtn(txt) {
    const b = document.createElement("button");
    b.className = "cbz-dl-btn";
    b.textContent = txt;
    return b;
  }

  /* ===== Gestion bibliothèque ===== */
  function openLibraryManager() {
    const old = document.getElementById("cbz-lib-dialog"); if (old) old.remove();
    const box = document.createElement("div"); box.id = "cbz-lib-dialog";
    box.innerHTML = '<div class="cbz-lib-box"><div class="cbz-lib-title">Bibliothèque CBZ (<span class="cbz-lib-count"></span>)</div><div class="cbz-lib-list"></div><div class="cbz-lib-actions"><button id="cbz-lib-clear">Tout effacer</button><button id="cbz-lib-close">Fermer</button></div></div>';
    document.body.appendChild(box);
    const list = box.querySelector(".cbz-lib-list");
    const count = box.querySelector(".cbz-lib-count");
    function render() {
      const names = Object.keys(library).sort();
      count.textContent = names.length;
      list.innerHTML = "";
      if (!names.length) { list.innerHTML = '<div style="opacity:.7;font-size:12px">Aucune entrée.</div>'; return; }
      names.forEach(n => {
        const row = document.createElement("div"); row.className = "cbz-lib-row";
        const nm = document.createElement("div"); nm.className = "cbz-lib-name"; nm.textContent = n;
        const del = document.createElement("button"); del.className = "cbz-lib-del"; del.textContent = "✕";
        del.onclick = () => { delete library[n]; saveLibrary(); render(); updateManageCount(); };
        row.appendChild(nm); row.appendChild(del); list.appendChild(row);
      });
    }
    render();
    box.querySelector("#cbz-lib-clear").onclick = () => { library = {}; saveLibrary(); render(); updateManageCount(); showToast("info", "Bibliothèque effacée"); };
    box.querySelector("#cbz-lib-close").onclick = () => box.remove();
    box.addEventListener("click", e => { if (e.target === box) box.remove(); });
  }
  function initManageButton() {
    if (document.getElementById("cbz-manage-btn")) return;
    const b = makeBtn("🗑 CBZ");
    b.id = "cbz-manage-btn";
    b.onclick = e => { e.preventDefault(); e.stopPropagation(); openLibraryManager(); };
    document.body.appendChild(b);
    updateManageCount();
  }

  /* ===== Téléchargement d'images générique ===== */
  async function downloadImagesSequential(urls, onStatus) {
    const results = [];
    for (let i = 0; i < urls.length; i++) {
      const u = urls[i];
      let data = null;
      try { data = await req(u, "arraybuffer"); } catch (e) {}
      if (!data) { try { data = await req(u, "arraybuffer", { Referer: location.origin }); } catch (e) {} }
      if (!data) throw new Error("Échec image " + (i + 1));
      const ext = (u.match(/\.(png|jpe?g|webp|gif)$/i) || [, "jpg"])[1].replace("jpeg", "jpg");
      results.push({ name: String(i + 1).padStart(3, "0") + "." + ext, data });
      onStatus(i + 1 + "/" + urls.length, Math.round(((i + 1) / urls.length) * 100));
      await sleep(150);
    }
    return compress(results, p => onStatus("Zip " + p + "%", p));
  }

  function waitForLiveImages(grab, timeout = 20000) {
    return new Promise(res => {
      const initial = grab();
      if (initial.length) return res(initial);
      const start = Date.now();
      let scrolls = 0;
      const check = () => {
        const u = grab();
        if (u.length) return res(u);
        if (Date.now() - start > timeout || scrolls > 12) return res(u);
        window.scrollTo(0, document.body.scrollHeight);
        scrolls++;
        setTimeout(check, 600);
      };
      setTimeout(check, 600);
    });
  }

  const chapFile = (t, l) => sanitize(t + " - " + l) + ".cbz";
  const chapNum = l => parseInt((l.match(/(\d+)/) || [])[1] || "0", 10);
  const labelFromUrl = u => {
    const m = u.match(/\/(chapitre|chapter|capitulo)[\/-]?(\d+)/i);
    return m ? "Chapitre " + m[2] : u;
  };

  function stopAuto() {
    GM_setValue("auto_mode", "");
    GM_setValue("auto_queue", "[]");
    const s = document.getElementById("cbz-stop");
    if (s) s.remove();
  }

  /* ================= NHENTAI ================= */
  const getLangFromTags = t => {
    const x = (t||[]).find(y => y.type === "language" && y.name !== "translated");
    return x ? x.name : undefined;
  };
  async function getGallery(gid) {
    try {
      const d = await getJSON("https://nhentai.net/api/v2/galleries/" + gid);
      if (d && d.media_id && Array.isArray(d.pages)) {
        return { mid: String(d.media_id), title: (d.title && (d.title.english || d.title.japanese || d.title.pretty)) || ("gallery-" + gid), lang: getLangFromTags(d.tags), pages: d.pages.map((p, i) => ({ i: i + 1, ext: (String(p.path).match(/\.(\w+)$/) || [])[1] || "jpg" })) };
      }
    } catch (e) {}
    try {
      const d = await getJSON("https://nhentai.net/api/gallery/" + gid);
      if (d && d.media_id && d.images && d.images.pages) {
        return { mid: String(d.media_id), title: (d.title && (d.title.english || d.title.japanese)) || ("gallery-" + gid), lang: getLangFromTags(d.tags), pages: d.images.pages.map((p, i) => ({ i: i + 1, ext: EXT[p.t] || "jpg" })) };
      }
    } catch (e) {}
    throw new Error("Infos introuvables : " + gid);
  }
  async function getGalleryWithRetry(gid) {
    for (let a = 0; a < 3; a++) {
      try { return await getGallery(gid); }
      catch (e) {
        if (!/429|rate ?limit/i.test(e.message)) throw e;
        if (a < 2) { showToast("warning", "Pause anti-robot 10s…"); await sleep(10000); }
        else throw e;
      }
    }
  }
  async function fetchPageImage(mid, page) {
    for (const h of HOSTS) {
      try { return { data: await req("https://" + h + "/galleries/" + mid + "/" + page.i + "." + page.ext, "arraybuffer"), ext: page.ext }; }
      catch (e) { if (/404/.test(e.message)) break; await sleep(400); }
    }
    for (const ext of ["jpg","png","webp","gif"]) {
      if (ext === page.ext) continue;
      try { return { data: await req("https://i.nhentai.net/galleries/" + mid + "/" + page.i + "." + ext, "arraybuffer"), ext }; } catch (e) {}
    }
    throw new Error("Échec page " + page.i);
  }
  async function buildGalleryBlob(g, onStatus) {
    const total = g.pages.length, pad = String(total).length;
    const results = new Array(total), queue = [...g.pages];
    let done = 0;
    const worker = async () => {
      while (queue.length) {
        const p = queue.shift();
        const img = await fetchPageImage(g.mid, p);
        results[p.i - 1] = { name: String(p.i).padStart(pad, "0") + "." + img.ext, data: img.data };
        done++;
        onStatus(done + "/" + total, (100 * done) / total, "dl");
      }
    };
    await Promise.all(Array.from({ length: 4 }, worker));
    return compress(results.filter(Boolean), p => onStatus("Zip " + p + "%", p, "zip"));
  }
  async function runNhentai(gid, btn, label) {
    if (btn.dataset.busy) return;
    btn.dataset.busy = "1"; btn.disabled = true; btn.classList.remove("cbz-ok","cbz-err");
    try {
      const g = await getGalleryWithRetry(gid);
      showPanel(g.title);
      const blob = await buildGalleryBlob(g, (t, p, m) => { btn.textContent = t; updatePanel(p, t, m); });
      const filename = (sanitize(g.title) || ("gallery-" + g.mid)) + ".cbz";
      saveBlob(filename, blob);
      libSet(filename, { p: g.pages.length, s: blob.size, g: gid });
      btn.textContent = "✓ Terminé"; btn.classList.add("cbz-ok");
      updatePanel(100, "✓ Terminé", "zip"); hidePanel(3000);
      showToast("success", "✓ " + filename);
    } catch (e) {
      btn.textContent = "✗ " + e.message; btn.classList.add("cbz-err");
      updatePanel(100, "✗ Erreur"); hidePanel(4000);
      showToast("error", "✗ " + e.message);
    } finally { btn.disabled = false; delete btn.dataset.busy; }
  }
  function initNhentaiDetail() {
    const gid = (location.pathname.match(/\/g\/(\d+)/) || [])[1];
    const box = document.querySelector("#info .buttons") || document.querySelector("#info");
    if (!gid || !box || box.querySelector("[data-cbz-detail]")) return;
    const btn = makeBtn("Download .cbz");
    btn.dataset.cbzDetail = "1";
    box.appendChild(btn);
    btn.onclick = () => runNhentai(gid, btn, "Download .cbz");
  }
  function initNhentaiList() {
    document.querySelectorAll(".gallery").forEach(g => {
      if (g.dataset.cbzInit) return;
      const a = g.querySelector("a.cover") || g.querySelector("a");
      if (!a) return;
      const gid = ((a.getAttribute("href") || "").match(/\/g\/(\d+)/) || [])[1];
      if (!gid) return;
      g.dataset.cbzInit = "1";
      const btn = makeBtn("⬇ CBZ");
      btn.classList.add("cbz-small");
      g.appendChild(btn);
      btn.onclick = e => { e.preventDefault(); e.stopPropagation(); runNhentai(gid, btn, "⬇ CBZ"); };
    });
  }
  function initNhentai() {
    initNhentaiDetail();
    initNhentaiList();
    let timer = null;
    new MutationObserver(() => {
      clearTimeout(timer);
      timer = setTimeout(() => { initNhentaiDetail(); initNhentaiList(); }, 200);
    }).observe(document.body, { childList: true, subtree: true });
  }

  /* ================= ORTEGA (XHR, sans navigation) ================= */
  let ortegaBulkRunning = false;
  const makeOrtegaFilename = (title, num) => sanitize(title + " - Chapitre " + num) + ".cbz";
  function ortegaTitleFromDOM() {
    const bc = document.querySelector('nav[aria-label*="readcrumb"], .breadcrumb, [class*="breadcrumb"]');
    if (bc) {
      const items = [...bc.querySelectorAll("a,span,li")].map(x => x.textContent.trim()).filter(Boolean);
      if (items.length >= 2) return items[items.length - 2] || items[items.length - 1];
    }
    const h1 = document.querySelector("h1");
    if (h1 && !/chapitre/i.test(h1.textContent)) return h1.textContent.trim();
    return document.title.split(/[|–-]/)[0].trim();
  }
  function parseOrtegaRSC(html) {
    const chunkRegex = /self\.__next_f\.push\(\[\d+,"([\s\S]*?)"\]\)/g;
    let match;
    while ((match = chunkRegex.exec(html)) !== null) {
      const chunkContent = match[1];
      let decoded;
      try { decoded = chunkContent.replace(/\\n/g,'\n').replace(/\\"/g,'"').replace(/\\\\/g,'\\'); }
      catch (e) { continue; }
      if (decoded.includes('"chapters"') && decoded.includes('"slug"')) {
        const chapMatch = decoded.match(/"chapters":\s*\[([\s\S]*?)\]/);
        if (chapMatch) { try { return JSON.parse('[' + chapMatch[1] + ']'); } catch (e) {} }
      }
    }
    return [];
  }
  async function fetchOrtegaChapterImages(chapUrl) {
    const html = await req(chapUrl, "text");
    const imgRegex = /<img[^>]*src="([^"]*\/api\/chapters\/[^"]*\/image\/[^"]*)"[^>]*>/gi;
    const urls = [];
    let match;
    while ((match = imgRegex.exec(html)) !== null) {
      let src = match[1];
      if (!src.startsWith('http')) src = location.origin + src;
      urls.push(src);
    }
    return urls;
  }
  async function bulkDownloadOrtega() {
    if (ortegaBulkRunning) { showToast("error", "Déjà en cours"); return; }
    ortegaBulkRunning = true;
    let stopRequested = false;
    showPanel("Téléchargement groupé");
    updatePanel(0, "Chargement de la liste…");
    const title = ortegaTitleFromDOM();
    const slug = location.pathname.match(/\/serie\/([^\/\?]+)/)?.[1];
    const chapters = parseOrtegaRSC(document.documentElement.outerHTML)
      .map(c => ({ number: c.number, url: location.origin + '/serie/' + slug + '/chapter/' + c.number, filename: makeOrtegaFilename(title, c.number) }))
      .sort((a, b) => a.number - b.number);
    if (!chapters.length) { showToast("error", "Aucun chapitre trouvé"); ortegaBulkRunning = false; hidePanel(2000); return; }
    const remaining = chapters.filter(c => !libGet(c.filename));
    if (!remaining.length) { showToast("info", "Déjà tout téléchargé (🗑 CBZ pour effacer)"); ortegaBulkRunning = false; hidePanel(2000); return; }
    const stopBtn = document.createElement("button");
    stopBtn.id = "cbz-stop"; stopBtn.textContent = "⏹ STOP";
    stopBtn.onclick = () => { stopRequested = true; };
    document.body.appendChild(stopBtn);
    let done = 0, failed = 0;
    for (let i = 0; i < remaining.length; i++) {
      if (stopRequested) break;
      const chap = remaining[i];
      updatePanel((100 * i) / remaining.length, `${i + 1}/${remaining.length}`, "dl", "Chapitre " + chap.number);
      try {
        const imgs = await fetchOrtegaChapterImages(chap.url);
        if (!imgs.length) { failed++; continue; }
        const blob = await downloadImagesSequential(imgs, (t, p) => updatePanel(((i + p/100) / remaining.length) * 100, `${i + 1}/${remaining.length} — ${t}`, p >= 100 ? "zip" : "dl", "Chapitre " + chap.number));
        saveBlob(chap.filename, blob);
        libSet(chap.filename, { p: imgs.length, s: blob.size, g: chap.url });
        done++;
        showToast("success", "✓ Chapitre " + chap.number, 1200);
      } catch (e) { failed++; showToast("error", "✗ Chapitre " + chap.number, 2000); }
      await sleep(400);
    }
    stopBtn.remove(); ortegaBulkRunning = false;
    updatePanel(100, `Terminé : ${done} ✓, ${failed} ✗`, "zip");
    showToast("success", `Terminé : ${done} chapitres`, 8000);
    setTimeout(() => hidePanel(3000), 3000);
  }
  async function downloadOrtegaSingleChapter() {
    const m = location.pathname.match(/\/serie\/([^\/]+)\/chapter\/(\d+)/);
    if (!m) { showToast("error", "URL non reconnue"); return; }
    const num = parseInt(m[2], 10);
    const btn = document.getElementById("cbz-chap-btn");
    if (btn) { btn.disabled = true; btn.textContent = "…"; }
    const title = ortegaTitleFromDOM();
    const filename = makeOrtegaFilename(title, num);
    showPanel(title);
    updatePanel(0, "Chargement des images…", "dl", "Chapitre " + num);
    try {
      const imgs = await fetchOrtegaChapterImages(location.href);
      if (!imgs.length) { showToast("error", "Aucune image"); hidePanel(2000); if (btn) { btn.disabled = false; btn.textContent = "⬇ Ce chapitre en CBZ"; } return; }
      const blob = await downloadImagesSequential(imgs, (t, p) => updatePanel(p, t, p >= 100 ? "zip" : "dl", "Chapitre " + num));
      saveBlob(filename, blob);
      libSet(filename, { p: imgs.length, s: blob.size, g: location.href });
      showToast("success", "✓ " + filename);
      if (btn) { btn.disabled = false; btn.textContent = "✓ Téléchargé"; btn.classList.add("cbz-ok"); }
      updatePanel(100, "✓ Terminé", "zip"); hidePanel(2000);
    } catch (e) {
      showToast("error", "Échec : " + e.message);
      if (btn) { btn.disabled = false; btn.textContent = "✗ Erreur"; btn.classList.add("cbz-err"); }
      hidePanel(3000);
    }
  }
  function initOrtega() {
    setTimeout(() => {
      try {
        if (/\/serie\/[^\/]+\/?$/.test(location.pathname) && !document.getElementById("cbz-bulk-btn")) {
          const b = makeBtn("⬇ Tous les chapitres");
          b.id = "cbz-bulk-btn"; b.classList.add("cbz-float");
          document.body.appendChild(b);
          b.onclick = e => { e.preventDefault(); e.stopPropagation(); bulkDownloadOrtega(); };
        }
        if (/\/serie\/[^\/]+\/chapter\/\d+/.test(location.pathname) && !document.getElementById("cbz-chap-btn")) {
          const b = makeBtn("⬇ Ce chapitre en CBZ");
          b.id = "cbz-chap-btn";
          const main = document.querySelector("main") || document.body;
          main.insertBefore(b, main.firstChild);
          b.onclick = e => { e.preventDefault(); e.stopPropagation(); downloadOrtegaSingleChapter(); };
        }
      } catch (e) { log("init ortega erreur:", e); }
    }, 1500);
  }

  /* ================= X-MANGA (rétabli, mode auto navigation) ================= */
  function xmangaGrab() {
    return [...new Set([...document.querySelectorAll("img")].map(i => i.dataset.src || i.src).filter(u => /reader\.hentai\.gifts\//.test(u)))];
  }
  function getXmangaSeriesInfo() {
    const title = (document.querySelector("h1")?.textContent || document.title.split(/[|–-]/)[0]).trim();
    const seen = new Map();
    document.querySelectorAll("a").forEach(a => {
      if (CHAP_RE.test(a.href) && !seen.has(a.href)) seen.set(a.href, { url: a.href, label: a.textContent.trim() || labelFromUrl(a.href) });
    });
    return { title, chapters: [...seen.values()] };
  }
  function xmangaStartAuto(info) {
    const chapters = [...info.chapters].sort((a, b) => chapNum(a.label) - chapNum(b.label));
    const remaining = chapters.filter(c => !libGet(chapFile(info.title, c.label)));
    if (!remaining.length) { showToast("info", "Déjà tout téléchargé (🗑 CBZ pour effacer)"); return; }
    GM_setValue("auto_mode", "xmanga");
    GM_setValue("auto_title", info.title);
    GM_setValue("auto_queue", JSON.stringify(remaining.map(c => ({ url: c.url, label: c.label }))));
    showToast("info", "Démarrage auto (" + remaining.length + ")");
    location.href = remaining[0].url;
  }
  async function xmangaAutoStep() {
    let queue = JSON.parse(GM_getValue("auto_queue", "[]"));
    const title = GM_getValue("auto_title", "série");
    if (!queue.length) { stopAuto(); showToast("success", "Lot terminé"); return; }
    if (!document.getElementById("cbz-stop")) {
      const s = document.createElement("button");
      s.id = "cbz-stop"; s.textContent = "⏹ STOP auto";
      s.onclick = () => { stopAuto(); showToast("info", "Mode auto arrêté"); hidePanel(); };
      document.body.appendChild(s);
    }
    const cur = normUrl(location.href);
    let idx = queue.findIndex(q => normUrl(q.url) === cur);
    const current = idx >= 0 ? queue[idx] : queue[0];
    showPanel("Auto : " + title);
    updatePanel(0, "Lecture des images…", "dl", current.label);
    const urls = await waitForLiveImages(xmangaGrab);
    if (urls.length) {
      try {
        const blob = await downloadImagesSequential(urls, (t, p) => updatePanel(p, t, p >= 100 ? "zip" : "dl", current.label));
        const fname = chapFile(title, current.label);
        saveBlob(fname, blob);
        libSet(fname, { p: urls.length, s: blob.size, g: location.href });
        showToast("success", "✓ " + current.label, 1500);
      } catch (e) { showToast("error", "✗ " + current.label, 2000); }
    } else {
      showToast("warning", "Images introuvables : " + current.label);
    }
    if (idx >= 0) queue.splice(idx, 1); else queue.shift();
    GM_setValue("auto_queue", JSON.stringify(queue));
    if (queue.length) {
      updatePanel(100, "→ suivant…", "zip");
      await sleep(1500);
      location.href = queue[0].url;
    } else {
      stopAuto(); hidePanel();
      showToast("success", "Lot terminé", 8000);
    }
  }
  function initXmanga() {
    setTimeout(() => {
      try {
        if (GM_getValue("auto_mode") === "xmanga" && CHAP_RE.test(location.href)) {
          xmangaAutoStep();
          return;
        }
        if (CHAP_RE.test(location.href) && !document.getElementById("cbz-chap-btn")) {
          const title = (document.querySelector("h1")?.textContent || document.title.split(/[|–-]/)[0]).trim();
          const label = labelFromUrl(location.href);
          const fname = chapFile(title, label);
          const b = makeBtn("⬇ Ce chapitre en CBZ");
          b.id = "cbz-chap-btn";
          const main = document.querySelector("#main-container") || document.body;
          main.insertBefore(b, main.firstChild);
          b.onclick = async e => {
            e.preventDefault(); e.stopPropagation();
            b.disabled = true; b.textContent = "…";
            showPanel(title);
            updatePanel(0, "Lecture des images…", "dl", label);
            try {
              const urls = await waitForLiveImages(xmangaGrab);
              if (!urls.length) { showToast("error", "Aucune image"); b.disabled = false; b.textContent = "⬇ Ce chapitre en CBZ"; hidePanel(2000); return; }
              const blob = await downloadImagesSequential(urls, (t, p) => updatePanel(p, t, p >= 100 ? "zip" : "dl", label));
              saveBlob(fname, blob);
              libSet(fname, { p: urls.length, s: blob.size, g: location.href });
              b.disabled = false; b.textContent = "✓ Téléchargé"; b.classList.add("cbz-ok");
              showToast("success", "✓ " + fname);
              updatePanel(100, "✓ Terminé", "zip"); hidePanel(2000);
            } catch (err) {
              b.disabled = false; b.textContent = "✗ Erreur"; b.classList.add("cbz-err");
              showToast("error", "Échec : " + err.message); hidePanel(3000);
            }
          };
        } else if (!CHAP_RE.test(location.href) && !document.getElementById("cbz-bulk-btn")) {
          const b = makeBtn("⬇ Tous les chapitres (auto)");
          b.id = "cbz-bulk-btn"; b.classList.add("cbz-float");
          document.body.appendChild(b);
          b.onclick = e => { e.preventDefault(); e.stopPropagation(); xmangaStartAuto(getXmangaSeriesInfo()); };
        }
      } catch (e) { log("init xmanga erreur:", e); }
    }, 1500);
  }

  log("Script v0.4.8 chargé sur:", location.href);
  initManageButton();
  if (IS_NHENTAI) initNhentai();
  else if (IS_ORTEGA) initOrtega();
  else if (IS_XMANGA) initXmanga();
})();