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와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey 또는 Userscripts와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 Tampermonkey와 같은 확장 프로그램이 필요합니다.

이 스크립트를 설치하려면 유저 스크립트 관리자 확장 프로그램이 필요합니다.

(이미 유저 스크립트 관리자가 설치되어 있습니다. 설치를 진행합니다!)

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 Stylus와 같은 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

이 스타일을 설치하려면 유저 스타일 관리자 확장 프로그램이 필요합니다.

(이미 유저 스타일 관리자가 설치되어 있습니다. 설치를 진행합니다!)

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