CBZ Downloader (Multi-site)

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

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Tendrás que instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Tendrás que instalar una extensión como Tampermonkey antes de poder instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Tendrás que instalar una extensión como Stylus antes de poder instalar este script.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

Para poder instalar esto tendrás que instalar primero una extensión de estilos de usuario.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

// ==UserScript==
// @name         CBZ Downloader (Multi-site)
// @namespace    local.cbz.downloader
// @version      0.4.1
// @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/*
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        unsafeWindow
// @connect      nhentai.net
// @connect      i.nhentai.net
// @connect      i1.nhentai.net
// @connect      i2.nhentai.net
// @connect      i3.nhentai.net
// @connect      i4.nhentai.net
// @connect      i5.nhentai.net
// @connect      i6.nhentai.net
// @connect      i7.nhentai.net
// @connect      x-manga.org
// @connect      x-manga.net
// @connect      reader.hentai.gifts
// @connect      ortegascans.fr
// @connect      *
// @run-at       document-idle
// @noframes
// ==/UserScript==

(function () {
  "use strict";

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

  GM_addStyle(`
    .cbz-dl-btn{margin:4px;padding:6px 14px;border:0;border-radius:4px;background:#ed2553;color:#fff;font-weight:700;cursor:pointer;font-size:13px;white-space:nowrap;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));

  const ZIP_WORKER_CODE = [
    'importScripts("https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js");',
    'self.onmessage=function(e){',
    '  var d=e.data;',
    '  var zip=new JSZip();',
    '  for(var i=0;i<d.files.length;i++)zip.file(d.files[i].name,d.files[i].data);',
    '  zip.generateAsync({type:"arraybuffer",compression:"STORE"},function(m){',
    '    self.postMessage({id:d.id,type:"progress",percent:m.percent});',
    '  }).then(function(b){',
    '    self.postMessage({id:d.id,type:"done",buffer:b},[b]);',
    '  },function(err){',
    '    self.postMessage({id:d.id,type:"error",message:String(err)});',
    '  });',
    '};'
  ].join("\n");
  let zipWorker = null, workerBroken = false, jobId = 0;
  const zipJobs = new Map();
  function getZipWorker() {
    if (zipWorker) return zipWorker;
    const u = URL.createObjectURL(new Blob([ZIP_WORKER_CODE], { type: "application/javascript" }));
    zipWorker = new Worker(u);
    zipWorker.onmessage = e => {
      const { id, type, percent, buffer, message } = e.data;
      const j = zipJobs.get(id);
      if (!j) return;
      if (type === "progress") j.onProgress && j.onProgress(percent);
      else if (type === "done") { zipJobs.delete(id); j.resolve(buffer); }
      else { zipJobs.delete(id); j.reject(new Error(message)); }
    };
    zipWorker.onerror = () => { workerBroken = true; zipJobs.forEach(j => j.reject(new Error("worker"))); zipJobs.clear(); };
    return zipWorker;
  }
  async function compress(files, onProgress) {
    if (!workerBroken) {
      try {
        const buf = await new Promise((res, rej) => {
          const id = ++jobId;
          zipJobs.set(id, { resolve: res, reject: rej, onProgress });
          getZipWorker().postMessage({ id, files });
        });
        return new Blob([buf], { type: "application/zip" });
      } catch (e) { log("Worker HS:", e.message); }
    }
    const zip = new JSZip();
    files.forEach(f => zip.file(f.name, f.data));
    return zip.generateAsync({ type: "blob", compression: "STORE" }, m => onProgress && onProgress(m.percent));
  }

  function saveBlob(name, blob) {
    const a = document.createElement("a");
    a.href = URL.createObjectURL(blob);
    a.download = name;
    document.body.appendChild(a);
    a.click();
    setTimeout(() => { URL.revokeObjectURL(a.href); a.remove(); }, 5000);
  }
  function makeBtn(txt) {
    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) { log("API v2:", e.message); }
    try {
      const d = await getJSON("https://nhentai.net/api/gallery/" + gid);
      if (d && d.media_id && d.images && d.images.pages) {
        return {
          mid: String(d.media_id),
          title: (d.title && (d.title.english || d.title.japanese)) || ("gallery-" + gid),
          lang: getLangFromTags(d.tags),
          pages: d.images.pages.map((p, i) => ({ i: i + 1, ext: EXT[p.t] || "jpg" }))
        };
      }
    } catch (e) { log("Ancienne API:", e.message); }
    throw new Error("Infos introuvables : " + gid);
  }

  async function getGalleryWithRetry(gid) {
    for (let a = 0; a < 3; a++) {
      try { return await getGallery(gid); }
      catch (e) {
        if (!/429|rate ?limit/i.test(e.message)) throw e;
        if (a < 2) { showToast("warning", "Pause anti-robot 10s…"); await sleep(10000); }
        else throw e;
      }
    }
  }

  async function fetchPageImage(mid, page) {
    for (const h of HOSTS) {
      try { return { data: await req("https://" + h + "/galleries/" + mid + "/" + page.i + "." + page.ext, "arraybuffer"), ext: page.ext }; }
      catch (e) { if (/404/.test(e.message)) break; await sleep(400); }
    }
    for (const ext of ["jpg","png","webp","gif"]) {
      if (ext === page.ext) continue;
      try { return { data: await req("https://i.nhentai.net/galleries/" + mid + "/" + page.i + "." + ext, "arraybuffer"), ext }; } catch (e) {}
    }
    throw new Error("Échec page " + page.i);
  }

  async function buildGalleryBlob(g, onStatus) {
    const total = g.pages.length, pad = String(total).length;
    const results = new Array(total), queue = [...g.pages];
    let done = 0;
    const worker = async () => {
      while (queue.length) {
        const p = queue.shift();
        const img = await fetchPageImage(g.mid, p);
        results[p.i - 1] = { name: String(p.i).padStart(pad, "0") + "." + img.ext, data: img.data };
        done++;
        onStatus(done + "/" + total, (100 * done) / total, "dl");
      }
    };
    await Promise.all(Array.from({ length: 4 }, worker));
    return compress(results.filter(Boolean), p => onStatus("Zip " + Number(p).toFixed(0) + "%", p, "zip"));
  }

  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 : TOUT EN XHR ================= */
  let bulkRunning = false;
  let stopRequested = false;

  // v0.4.1 : format de nom UNIFIÉ pour les deux modes
  function makeOrtegaFilename(seriesTitle, chapNumber, chapTitle) {
    let label = "Chapitre " + chapNumber;
    if (chapTitle) label += " - " + chapTitle;
    return sanitize(seriesTitle + " - " + label) + ".cbz";
  }

  // Parse le payload RSC pour extraire les infos série + chapitres
  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]*?)\]/);
        // Cherche le titre de la série dans le même chunk ou un autre
        const titleMatch = decoded.match(/"name":"([^"]+)"/);
        if (chapMatch) {
          try {
            const chapters = JSON.parse('[' + chapMatch[1] + ']');
            return { chapters, rawTitle: titleMatch ? titleMatch[1] : null };
          } catch (e) {}
        }
      }
    }
    return { chapters: [], rawTitle: null };
  }

  async function getOrtegaSeriesInfo() {
    const html = document.documentElement.outerHTML;
    const { chapters, rawTitle } = parseOrtegaRSC(html);
    
    const slug = location.pathname.match(/\/serie\/([^\/\?]+)/)?.[1];
    if (!slug) return { title: "", chapters: [] };
    
    // Titre : depuis le RSC ou depuis le DOM
    let title = rawTitle || document.querySelector("h1")?.textContent || document.title.split(/[|–-]/)[0];
    title = title.trim();
    
    if (!chapters.length) return { title, chapters: [] };
    
    return {
      title,
      chapters: chapters.map(c => ({
        url: location.origin + '/serie/' + slug + '/chapter/' + c.number,
        number: c.number,
        chapTitle: c.title || null,
        label: 'Chapitre ' + c.number + (c.title ? ' - ' + c.title : ''),
        filename: makeOrtegaFilename(title, c.number, c.title || null),
        isPremium: c.isPremium
      }))
    };
  }

  // Fetch le HTML d'un chapitre et extrait les URLs d'images
  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 trouvées pour", chapUrl, ":", urls.length);
    return urls;
  }

  async function downloadImages(urls, onStatus) {
    const results = new Array(urls.length), queue = urls.map((u, i) => ({ u, i }));
    let done = 0;
    const worker = async () => {
      while (queue.length) {
        const { u, i } = queue.shift();
        let data = null;
        try { data = await req(u, "arraybuffer"); } catch (e) {}
        if (!data) {
          try { data = await req(u, "arraybuffer", { Referer: location.origin, Origin: location.origin }); }
          catch (e) {}
        }
        if (!data) throw new Error("Échec image " + (i + 1));
        const ext = (u.match(/\.(png|jpe?g|webp|gif)$/i) || [, "jpg"])[1].replace("jpeg", "jpg");
        results[i] = { name: String(i + 1).padStart(3, "0") + "." + ext, data };
        done++;
        onStatus(done + "/" + urls.length, (100 * done) / urls.length);
        await sleep(100);
      }
    };
    await Promise.all(Array.from({ length: 2 }, worker));
    return compress(results.filter(Boolean), p => onStatus("Zip " + Number(p).toFixed(0) + "%", p, "zip"));
  }

  async function bulkDownloadOrtega() {
    if (bulkRunning) { showToast("error", "Téléchargement déjà en cours"); return; }
    bulkRunning = true;
    stopRequested = false;

    showPanel("Téléchargement groupé");
    updatePanel(0, "Chargement de la liste…");

    let info;
    try {
      info = await getOrtegaSeriesInfo();
    } catch (e) {
      showToast("error", "Impossible de charger la liste: " + e.message);
      bulkRunning = false;
      hidePanel(2000);
      return;
    }

    if (!info.chapters.length) {
      showToast("error", "Aucun chapitre trouvé");
      bulkRunning = false;
      hidePanel(2000);
      return;
    }

    const chapters = [...info.chapters].sort((a, b) => a.number - b.number);
    // v0.4.1 : on utilise info.filename (le même que pour le mode chapitre par chapitre)
    const remaining = chapters.filter(c => !libGet(c.filename));

    log("Total:", chapters.length, "| Restants:", remaining.length);

    if (!remaining.length) {
      showToast("info", "Tous les chapitres déjà téléchargés");
      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, skipped = 0;

    for (let i = 0; i < remaining.length; i++) {
      if (stopRequested) {
        log("Arrêt demandé");
        break;
      }
      const chap = remaining[i];

      updatePanel(
        (100 * i) / remaining.length,
        `${i + 1}/${remaining.length}`,
        "dl",
        chap.label
      );

      try {
        log("Téléchargement:", chap.url);
        const imgs = await fetchOrtegaChapterImages(chap.url);
        if (!imgs.length) {
          log("Aucune image pour", chap.label);
          failed++;
          continue;
        }
        const blob = await downloadImages(imgs, (t, p) => {
          updatePanel(
            ((i + p/100) / remaining.length) * 100,
            `${i + 1}/${remaining.length} — ${t}`,
            p >= 100 ? "zip" : "dl",
            chap.label
          );
        });
        saveBlob(chap.filename, blob);
        libSet(chap.filename, { p: imgs.length, s: blob.size, g: chap.url });
        done++;
        log("✓", chap.filename);
        showToast("success", "✓ " + chap.label, 1500);
      } catch (e) {
        log("✗ Échec", chap.label, e.message);
        failed++;
        showToast("error", "✗ " + chap.label + " : " + e.message, 3000);
      }

      if (i < remaining.length - 1) await sleep(500);
    }

    stopBtn.remove();
    bulkRunning = false;
    updatePanel(100, `Terminé : ${done} ✓, ${failed} ✗`, "zip");
    showToast("success", `Terminé : ${done} chapitres${failed ? ", " + failed + " échecs" : ""}`, 8000);
    setTimeout(() => hidePanel(3000), 3000);
  }

  // v0.4.1 : téléchargement d'un chapitre UNIQUE (même nom que le mode groupé)
  async function downloadOrtegaSingleChapter() {
    const urlMatch = location.pathname.match(/\/serie\/([^\/]+)\/chapter\/(\d+)/);
    if (!urlMatch) { showToast("error", "URL non reconnue"); return; }
    const slug = urlMatch[1];
    const chapNumber = parseInt(urlMatch[2], 10);

    const btn = document.getElementById("cbz-chap-btn");
    if (btn) { btn.disabled = true; btn.textContent = "…"; }

    // Récupérer le titre de la série depuis le HTML
    let seriesTitle = "";
    try {
      const seriesUrl = location.origin + '/serie/' + slug;
      const html = await req(seriesUrl, "text");
      const { rawTitle } = parseOrtegaRSC(html);
      seriesTitle = rawTitle || document.querySelector("h1")?.textContent || document.title.split(/[|–-]/)[0];
      seriesTitle = seriesTitle.trim();
    } catch (e) {
      seriesTitle = document.querySelector("h1")?.textContent || document.title.split(/[|–-]/)[0];
    }

    // Chercher le sous-titre du chapitre dans le RSC de la série
    let chapTitle = null;
    try {
      const seriesUrl = location.origin + '/serie/' + slug;
      const html = await req(seriesUrl, "text");
      const { chapters } = parseOrtegaRSC(html);
      const chap = chapters.find(c => c.number === chapNumber);
      if (chap && chap.title) chapTitle = chap.title;
    } catch (e) {}

    const filename = makeOrtegaFilename(seriesTitle, chapNumber, chapTitle);

    // Vérifier si déjà téléchargé
    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(seriesTitle);
    updatePanel(0, "Chargement des images…", "dl", "Chapitre " + chapNumber);

    try {
      const imgs = await fetchOrtegaChapterImages(location.href);
      if (!imgs.length) {
        showToast("error", "Aucune image trouvée");
        hidePanel(2000);
        if (btn) { btn.disabled = false; btn.textContent = "⬇ Ce chapitre"; }
        return;
      }
      const blob = await downloadImages(imgs, (t, p) => {
        updatePanel(p, t, p >= 100 ? "zip" : "dl", "Chapitre " + chapNumber);
      });
      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) {
      log("Échec:", e.message);
      showToast("error", "Échec : " + e.message);
      if (btn) { btn.disabled = false; btn.textContent = "✗ Erreur"; btn.classList.add("cbz-err"); }
      hidePanel(3000);
    }
  }

  // v0.4.1 : bouton bulk UNIQUEMENT sur pages /serie/ (pas sur /chapter/)
  function initOrtegaBulk() {
    if (!/\/serie\/[^\/]+\/?$/.test(location.pathname)) return; // v0.4.1 : regex stricte
    if (document.getElementById("cbz-bulk-btn")) return;
    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(); };
    log("Bouton bulk créé (page série uniquement)");
  }

  // v0.4.1 : bouton "Ce chapitre" UNIQUEMENT sur pages /chapter/
  function initOrtegaChap() {
    if (!/\/serie\/[^\/]+\/chapter\/\d+/.test(location.pathname)) return;
    if (document.getElementById("cbz-chap-btn")) return;
    const b = makeBtn("⬇ Ce chapitre en CBZ");
    b.id = "cbz-chap-btn";
    // Insérer en haut du contenu principal
    const main = document.querySelector("main") || document.querySelector("#main-container") || document.body;
    main.insertBefore(b, main.firstChild);
    b.onclick = (e) => { e.preventDefault(); e.stopPropagation(); downloadOrtegaSingleChapter(); };
    log("Bouton chapitre créé");
  }

  function initOrtega() {
    initOrtegaBulk();
    initOrtegaChap();
  }

  /* ================= XMANGA ================= */
  function initXmanga() {
    log("x-manga : pas implémenté dans v0.4.1");
  }

  /* ================= INIT ================= */
  log("Script v0.4.1 chargé sur:", location.href);
  if (IS_NHENTAI) {
    initNhentai();
  } else if (IS_ORTEGA) {
    initOrtega();
  } else if (IS_XMANGA) {
    initXmanga();
  }
})();