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.

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला Tampermonkey यासारखे एक्स्टेंशन इंस्टॉल करावे लागेल..

ही स्क्रिप्ट इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्क्रिप्ट व्यवस्थापक एक्स्टेंशन इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्क्रिप्ट व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला Stylus सारखे एक्स्टेंशन इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

ही स्टाईल इंस्टॉल करण्यासाठी तुम्हाला एक युझर स्टाईल व्यवस्थापक इंस्टॉल करावे लागेल.

(माझ्याकडे आधीच युझर स्टाईल व्यवस्थापक आहे, मला इंस्टॉल करू द्या!)

// ==UserScript==
// @name         CBZ Downloader (Multi-site)
// @namespace    local.cbz.downloader
// @version      0.4.5
// @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";

  // v0.4.5 : empêcher deux copies de tourner en même temps
  if (window.__cbzLoaded) { console.log("[CBZ] déjà chargé, 2e copie ignorée"); return; }
  window.__cbzLoaded = true;

  // v0.4.5 : éteindre tout ancien "mode auto" pour qu'aucune vieille version ne boucle
  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-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}
  `);

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

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

  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 qui rend la main ===== */
  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;
  }

  /* ================= 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 (léger, séquentiel) ================= */
  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é"); 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);

    if (libGet(filename)) {
      showToast("info", "Déjà téléchargé : " + filename);
      if (btn) { btn.disabled = false; btn.textContent = "✓ Déjà téléchargé"; btn.classList.add("cbz-ok"); }
      return;
    }

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

  // v0.4.5 : boutons ajoutés après un délai, sans observateur ni intervalle
  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.5"); }

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