CBZ Downloader (Multi-site)

Télécharge nhentai.net, x-manga.org et ortegascans.fr en .cbz via XHR (sans navigation)

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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.7
// @description  Télécharge nhentai.net, x-manga.org et ortegascans.fr en .cbz via XHR (sans navigation)
// @description:fr  Télécharge nhentai.net, x-manga.org et ortegascans.fr en .cbz via XHR (sans navigation)
// @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;
  try { GM_setValue("auto_mode", false); GM_setValue("auto_queue", "[]"); } catch (e) {}

  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 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;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;right: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;
  }

  /* ===== v0.4.7 : gestion manuelle de la 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();
  }

  /* ================= 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 ================= */
  let bulkRunning = false;
  let stopRequested = 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);
    }
    log("Images pour", chapUrl, ":", urls.length);
    return urls;
  }

  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));
  }

  async function bulkDownloadOrtega() {
    if (bulkRunning) { showToast("error", "Déjà en cours"); return; }
    bulkRunning = true; 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é"); bulkRunning = false; hidePanel(2000); return; }
    const remaining = chapters.filter(c => !libGet(c.filename));
    if (!remaining.length) { showToast("info", "Déjà tout téléchargé (ouvre 🗑 CBZ pour effacer et forcer)"); bulkRunning = 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(); bulkRunning = 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);
  }

  function initXmanga() { log("x-manga : non implémenté dans v0.4.7"); }

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