BSReader Book Downloader

Download unscrambled pages as ZIP or PDF from MediaDo bsreader (parallel fetch)

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         BSReader Book Downloader
// @namespace    https://github.com/andylilfs0217/libby-media-do-downloader
// @version      1.3.4
// @description  Download unscrambled pages as ZIP or PDF from MediaDo bsreader (parallel fetch)
// @author       Andy Li
// @license      MIT
// @homepageURL  https://github.com/andylilfs0217/libby-media-do-downloader#readme
// @supportURL   https://github.com/andylilfs0217/libby-media-do-downloader/issues
// @contributionURL https://github.com/sponsors/andylilfs0217
// @match        https://api.distribution.mediadotech.com/viewers/bsreader/v2/index.html*
// @icon         https://www.google.com/s2/favicons?sz=64&domain=mediadotech.com
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js
// @grant        none
// @connect      cdnjs.cloudflare.com
// @connect      cdn.jsdelivr.net
// @run-at       document-idle
// ==/UserScript==
//
// ==OpenUserJS==
// @author andyli0217
// ==/OpenUserJS==

(function () {
  'use strict';

  const CONCURRENCY = 6;

  /** Tip jar (Ko-fi, GitHub Sponsors, PayPal, etc.). Set to '' to hide the link. */
  const DONATION_URL = 'https://github.com/sponsors/andylilfs0217';

  /**
   * Official Clip Studio / MediaDo viewer math (csr-web-core): each cell’s tile side length is
   * floored to a multiple of 8px; the remainder becomes unscrambled right/bottom strips outside the grid.
   * @returns {{ tw: number, th: number, padRight: number, padBottom: number }}
   */
  function computeOfficialTileLayout(w, h, gridW, gridH) {
    const tw = 8 * Math.floor(Math.floor(w / gridW) / 8);
    const th = 8 * Math.floor(Math.floor(h / gridH) / 8);
    const padRight = w - gridW * tw;
    const padBottom = h - gridH * th;
    return { tw, th, padRight, padBottom };
  }

  /** Console prefix for debugging (DevTools → Console). */
  const LOG = '[BSReader DL]';

  /**
   * @param {string} phase
   * @param {unknown} err
   * @param {Record<string, unknown>} [meta]
   */
  function logError(phase, err, meta) {
    console.error(LOG, phase, err);
    if (meta && Object.keys(meta).length) console.error(LOG, 'context:', meta);
    if (err instanceof Error && err.stack) console.error(LOG, 'stack:', err.stack);
  }

  function logJsPDFProbe(reason) {
    try {
      const w = window;
      const pkg = w.jspdf;
      console.error(LOG, 'jsPDF probe (' + reason + '):', {
        hasJspdf: !!pkg,
        jspdfType: pkg == null ? null : typeof pkg,
        jspdfKeys:
          pkg && typeof pkg === 'object' ? Object.keys(pkg).slice(0, 24) : null,
        hasGlobalJsPDF: typeof w.jsPDF,
        hasJSZip: typeof w.JSZip,
        hasSaveAs: typeof saveAs,
      });
    } catch (probeErr) {
      console.error(LOG, 'jsPDF probe threw', probeErr);
    }
  }

  /**
   * jsPDF UMD sets `global.jspdf = { jsPDF, default, ... }`. Tampermonkey may expose
   * that on the sandbox `window`, on `unsafeWindow` (page), or only after a DOM script tag load.
   * @returns {function|null}
   */
  function resolveJsPDFConstructor() {
    /** @type {Set<Window & typeof globalThis>} */
    const roots = new Set();
    try {
      if (typeof unsafeWindow !== 'undefined') roots.add(unsafeWindow);
    } catch (_) {}
    roots.add(window);
    if (typeof globalThis !== 'undefined') roots.add(globalThis);
    try {
      if (typeof window !== 'undefined' && window.wrappedJSObject) {
        roots.add(window.wrappedJSObject);
      }
    } catch (_) {}

    for (const root of roots) {
      if (!root) continue;
      try {
        const pkg = root.jspdf;
        if (pkg && typeof pkg.jsPDF === 'function') return pkg.jsPDF;
        if (pkg && typeof pkg.default === 'function') return pkg.default;
        if (typeof root.jsPDF === 'function') return root.jsPDF;
      } catch (_) {}
    }
    return null;
  }

  /**
   * Fallback if @require did not attach to this realm (blocked CDN, load order, etc.).
   * @param {string} url
   * @returns {Promise<void>}
   */
  function appendJsPDFScript(url) {
    return new Promise((resolve, reject) => {
      if ([...document.scripts].some((s) => s.src === url)) {
        resolve();
        return;
      }
      const s = document.createElement('script');
      s.src = url;
      s.async = true;
      s.dataset.bsreaderJspdfSrc = url;
      s.onload = () => resolve();
      s.onerror = () => reject(new Error('Failed to load: ' + url));
      (document.head || document.documentElement).appendChild(s);
    });
  }

  /** @returns {Promise<function>} */
  async function ensureJsPDFConstructor() {
    let Ctor = resolveJsPDFConstructor();
    if (Ctor) return Ctor;

    const urls = [
      'https://cdnjs.cloudflare.com/ajax/libs/jspdf/2.5.1/jspdf.umd.min.js',
      'https://cdn.jsdelivr.net/npm/[email protected]/dist/jspdf.umd.min.js',
    ];

    for (const url of urls) {
      try {
        await appendJsPDFScript(url);
        Ctor = resolveJsPDFConstructor();
        if (Ctor) return Ctor;
      } catch (e) {
        logError('jsPDF script load attempt failed', e, { url });
      }
    }

    logJsPDFProbe('ensureJsPDFConstructor: all attempts failed');
    throw new Error(
      'jsPDF could not be loaded. Check Tampermonkey script permissions, disable ad blockers for cdnjs/jsdelivr, or try updating Tampermonkey.'
    );
  }

  /** @type {typeof JSZip} */
  const ZipCtor = typeof JSZip !== 'undefined' ? JSZip : window.JSZip;

  /**
   * @template T
   * @param {number} count
   * @param {Array<() => Promise<T>>} tasks
   * @returns {Promise<T[]>}
   */
  async function withConcurrency(count, tasks) {
    if (tasks.length === 0) return /** @type {T[]} */ ([]);
    const results = /** @type {T[]} */ (new Array(tasks.length));
    let next = 0;
    async function worker() {
      while (next < tasks.length) {
        const i = next++;
        results[i] = await tasks[i]();
      }
    }
    const n = Math.min(count, tasks.length);
    await Promise.all(Array.from({ length: n }, () => worker()));
    return results;
  }

  /** @param {string} cgi */
  function buildApiUrl(cgi, mode, file, param, time) {
    const u = new URL(cgi);
    u.searchParams.set('mode', String(mode));
    u.searchParams.set('file', file);
    u.searchParams.set('reqtype', '0');
    u.searchParams.set('vm', '4');
    u.searchParams.set('param', param);
    u.searchParams.set('time', String(time));
    return u.toString();
  }

  function parseFaceXml(text) {
    const doc = new DOMParser().parseFromString(text, 'text/xml');
    const err = doc.querySelector('parsererror');
    if (err) throw new Error('face.xml parse error');
    const totalEl = doc.querySelector('TotalPage');
    const wEl = doc.querySelector('Scramble > Width');
    const hEl = doc.querySelector('Scramble > Height');
    const totalPage = totalEl ? parseInt(totalEl.textContent.trim(), 10) : 0;
    const gridW = wEl ? parseInt(wEl.textContent.trim(), 10) : 4;
    const gridH = hEl ? parseInt(hEl.textContent.trim(), 10) : 4;
    if (!Number.isFinite(totalPage) || totalPage < 1) {
      throw new Error('Invalid TotalPage in face.xml');
    }
    return { totalPage, gridW, gridH };
  }

  function parsePageXml(text) {
    const doc = new DOMParser().parseFromString(text, 'text/xml');
    const err = doc.querySelector('parsererror');
    if (err) throw new Error('page XML parse error');
    const scrambleEl = doc.querySelector('Page > Scramble') || doc.querySelector('Scramble');
    let scrambleOrder = [];
    if (scrambleEl && scrambleEl.textContent) {
      scrambleOrder = scrambleEl.textContent
        .split(',')
        .map((s) => parseInt(s.trim(), 10))
        .filter((n) => !Number.isNaN(n));
    }
    const parts = [];
    const partEls = doc.querySelectorAll('Part Kind');
    partEls.forEach((kind) => {
      const no = kind.getAttribute('No') || '0000';
      const scrambleAttr = kind.getAttribute('scramble');
      const needUnscramble = scrambleAttr !== '0';
      parts.push({ no, needUnscramble });
    });
    if (parts.length === 0) {
      parts.push({ no: '0000', needUnscramble: scrambleOrder.length > 0 });
    }
    return { scrambleOrder, parts };
  }

  /**
   * Unscramble tile grid; optional right/bottom strips are copied as-is (not part of scramble).
   * @param {HTMLImageElement | HTMLCanvasElement | ImageBitmap} img
   * @param {number[]} scrambleOrder
   * @param {number} gridW
   * @param {number} gridH
   */
  function unscrambleToCanvas(img, scrambleOrder, gridW, gridH) {
    const w = img.width;
    const h = img.height;
    const canvas = document.createElement('canvas');
    canvas.width = w;
    canvas.height = h;
    const ctx = canvas.getContext('2d');
    if (!ctx) throw new Error('2d context unavailable');
    const cells = gridW * gridH;
    if (scrambleOrder.length !== cells) {
      ctx.drawImage(img, 0, 0);
      return canvas;
    }

    const { tw, th, padRight, padBottom } = computeOfficialTileLayout(
      w,
      h,
      gridW,
      gridH
    );
    const useOfficial = tw > 0 && th > 0;

    if (useOfficial) {
      const contentW = gridW * tw;
      const contentH = gridH * th;

      for (let i = 0; i < cells; i++) {
        const srcIdx = scrambleOrder[i];
        const srcCol = srcIdx % gridW;
        const srcRow = Math.floor(srcIdx / gridW);
        const dstCol = i % gridW;
        const dstRow = Math.floor(i / gridW);
        const sx = srcCol * tw;
        const sy = srcRow * th;
        const dx = dstCol * tw;
        const dy = dstRow * th;
        const sw = Math.min(tw, contentW - sx);
        const sh = Math.min(th, contentH - sy);
        const dw = Math.min(tw, contentW - dx);
        const dh = Math.min(th, contentH - dy);
        const drawW = Math.min(sw, dw);
        const drawH = Math.min(sh, dh);
        ctx.drawImage(img, sx, sy, drawW, drawH, dx, dy, drawW, drawH);
      }

      if (padRight > 0) {
        ctx.drawImage(
          img,
          contentW,
          0,
          padRight,
          h,
          contentW,
          0,
          padRight,
          h
        );
      }
      if (padBottom > 0) {
        ctx.drawImage(
          img,
          0,
          contentH,
          w,
          padBottom,
          0,
          contentH,
          w,
          padBottom
        );
      }
      return canvas;
    }

    console.warn(
      LOG,
      'Official tile layout unavailable (tw/th); using full-image tile split. Image:',
      w,
      'x',
      h,
      'grid:',
      gridW,
      'x',
      gridH
    );

    const cellW = Math.ceil(w / gridW);
    const cellH = Math.ceil(h / gridH);
    for (let i = 0; i < cells; i++) {
      const srcIdx = scrambleOrder[i];
      const srcCol = srcIdx % gridW;
      const srcRow = Math.floor(srcIdx / gridW);
      const dstCol = i % gridW;
      const dstRow = Math.floor(i / gridW);
      const sx = srcCol * cellW;
      const sy = srcRow * cellH;
      const dx = dstCol * cellW;
      const dy = dstRow * cellH;
      const sw = Math.min(cellW, w - sx);
      const sh = Math.min(cellH, h - sy);
      const dw = Math.min(cellW, w - dx);
      const dh = Math.min(cellH, h - dy);
      const drawW = Math.min(sw, dw);
      const drawH = Math.min(sh, dh);
      ctx.drawImage(img, sx, sy, drawW, drawH, dx, dy, drawW, drawH);
    }
    return canvas;
  }

  function loadImageFromBlob(blob) {
    return new Promise((resolve, reject) => {
      const url = URL.createObjectURL(blob);
      const img = new Image();
      img.crossOrigin = 'anonymous';
      img.onload = () => {
        URL.revokeObjectURL(url);
        resolve(img);
      };
      img.onerror = () => {
        URL.revokeObjectURL(url);
        const err = new Error('Image load failed (decode or not an image)');
        logError('loadImageFromBlob', err, { blobSize: blob && blob.size });
        reject(err);
      };
      img.src = url;
    });
  }

  async function fetchBinary(cgi, param, mode, file, time) {
    const url = buildApiUrl(cgi, mode, file, param, time);
    const res = await fetch(url, {
      credentials: 'include',
      headers: {
        Accept: mode === 3 ? 'image/*,*/*' : '*/*',
        Referer: location.href,
      },
    });
    if (!res.ok) {
      const err = new Error(`HTTP ${res.status} for ${file}`);
      logError('fetchBinary', err, { mode, file, status: res.status, statusText: res.statusText });
      throw err;
    }
    return res.blob();
  }

  async function fetchText(cgi, param, mode, file, time) {
    const url = buildApiUrl(cgi, mode, file, param, time);
    const res = await fetch(url, {
      credentials: 'include',
      headers: {
        Accept: '*/*',
        Referer: location.href,
      },
    });
    if (!res.ok) {
      const err = new Error(`HTTP ${res.status} for ${file}`);
      logError('fetchText', err, { mode, file, status: res.status, statusText: res.statusText });
      throw err;
    }
    return res.text();
  }

  /**
   * @param {number} page
   * @param {string} cgi
   * @param {string} param
   * @param {number} timeBase
   * @param {number} gridW
   * @param {number} gridH
   * @returns {Promise<{ pageIndex: number, parts: Array<{no:string,needUnscramble:boolean}>, canvases: HTMLCanvasElement[] }>}
   */
  async function downloadAndRenderPage(page, cgi, param, timeBase, gridW, gridH) {
    const pageFile = `${String(page).padStart(4, '0')}.xml`;
    const pageXml = await fetchText(cgi, param, 8, pageFile, timeBase + page);
    const { scrambleOrder, parts } = parsePageXml(pageXml);
    const canvases = [];

    for (let pi = 0; pi < parts.length; pi++) {
      const { no, needUnscramble } = parts[pi];
      const binName = `${String(page).padStart(4, '0')}_${no}.bin`;
      const blob = await fetchBinary(cgi, param, 3, binName, timeBase + page * 1000 + pi);
      const img = await loadImageFromBlob(blob);

      let outCanvas;
      if (needUnscramble && scrambleOrder.length === gridW * gridH) {
        outCanvas = unscrambleToCanvas(img, scrambleOrder, gridW, gridH);
      } else {
        outCanvas = document.createElement('canvas');
        outCanvas.width = img.width;
        outCanvas.height = img.height;
        const ctx = outCanvas.getContext('2d');
        if (ctx) ctx.drawImage(img, 0, 0);
      }
      canvases.push(outCanvas);
    }

    return { pageIndex: page, parts, canvases };
  }

  /**
   * @param {{ pageIndex: number, parts: Array<{ no: string }> }} pageResult
   * @param {number} partIndex
   */
  function zipNameForPage(pageResult, partIndex) {
    const { pageIndex, parts } = pageResult;
    const no = parts[partIndex].no;
    return parts.length === 1
      ? `page_${String(pageIndex).padStart(4, '0')}.png`
      : `page_${String(pageIndex).padStart(4, '0')}_part_${no}.png`;
  }

  /**
   * @param {Awaited<ReturnType<typeof downloadAndRenderPage>>[]} pageResults
   */
  async function buildPdfFromCanvases(pageResults) {
    const flat = [];
    pageResults.forEach((r) => {
      r.canvases.forEach((c) => flat.push(c));
    });
    if (flat.length === 0) throw new Error('No pages to export');

    const jsPDF = await ensureJsPDFConstructor();

    const first = flat[0];
    const pdf = new jsPDF({
      unit: 'px',
      format: [first.width, first.height],
      orientation: first.width > first.height ? 'l' : 'p',
      compress: true,
    });
    pdf.addImage(
      first.toDataURL('image/jpeg', 0.88),
      'JPEG',
      0,
      0,
      first.width,
      first.height
    );

    for (let i = 1; i < flat.length; i++) {
      const c = flat[i];
      pdf.addPage([c.width, c.height], c.width > c.height ? 'l' : 'p');
      pdf.addImage(
        c.toDataURL('image/jpeg', 0.88),
        'JPEG',
        0,
        0,
        c.width,
        c.height
      );
    }

    return pdf.output('blob');
  }

  function injectUi() {
    if (document.getElementById('bsreader-book-dl-root')) return;

    const root = document.createElement('div');
    root.id = 'bsreader-book-dl-root';
    root.style.cssText = [
      'position:fixed',
      'top:10px',
      'right:10px',
      'z-index:2147483647',
      'font-family:system-ui,-apple-system,sans-serif',
      'font-size:12px',
      'line-height:1.35',
    ].join(';');

    const panel = document.createElement('div');
    panel.style.cssText = [
      'width:min(220px,calc(100vw - 20px))',
      'border-radius:10px',
      'border:1px solid rgba(148,163,184,.28)',
      'background:rgba(15,23,42,.88)',
      'backdrop-filter:saturate(1.2) blur(10px)',
      '-webkit-backdrop-filter:saturate(1.2) blur(10px)',
      'box-shadow:0 4px 24px rgba(0,0,0,.22),0 0 0 1px rgba(255,255,255,.04) inset',
      'color:#e2e8f0',
      'overflow:hidden',
    ].join(';');

    const header = document.createElement('div');
    header.style.cssText = [
      'display:flex',
      'align-items:center',
      'justify-content:space-between',
      'gap:8px',
      'padding:6px 8px 6px 10px',
      'border-bottom:1px solid rgba(148,163,184,.15)',
      'background:rgba(0,0,0,.12)',
    ].join(';');

    const title = document.createElement('span');
    title.textContent = 'Export';
    title.style.cssText =
      'font-size:11px;font-weight:600;letter-spacing:.04em;text-transform:uppercase;color:#94a3b8';

    const btnDismissPanel = document.createElement('button');
    btnDismissPanel.type = 'button';
    btnDismissPanel.setAttribute('aria-label', 'Hide download panel');
    btnDismissPanel.textContent = '×';
    btnDismissPanel.style.cssText = [
      'flex-shrink:0',
      'width:22px',
      'height:22px',
      'padding:0',
      'margin:0',
      'border:none',
      'border-radius:6px',
      'background:transparent',
      'color:#94a3b8',
      'font-size:18px',
      'line-height:1',
      'cursor:pointer',
      'display:flex',
      'align-items:center',
      'justify-content:center',
    ].join(';');
    btnDismissPanel.addEventListener('mouseenter', () => {
      btnDismissPanel.style.background = 'rgba(255,255,255,.08)';
      btnDismissPanel.style.color = '#f1f5f9';
    });
    btnDismissPanel.addEventListener('mouseleave', () => {
      btnDismissPanel.style.background = 'transparent';
      btnDismissPanel.style.color = '#94a3b8';
    });

    const actions = document.createElement('div');
    actions.style.cssText = 'display:flex;gap:6px;padding:8px 8px 8px';

    const btnBase = [
      'flex:1',
      'min-width:0',
      'padding:6px 8px',
      'border-radius:7px',
      'border:1px solid rgba(255,255,255,.12)',
      'font-size:11px',
      'font-weight:600',
      'letter-spacing:.02em',
      'cursor:pointer',
      'color:#f8fafc',
      'transition:filter .15s',
    ].join(';');

    const btnZip = document.createElement('button');
    btnZip.type = 'button';
    btnZip.title = 'Download all pages as PNG in a ZIP';
    btnZip.textContent = 'ZIP';
    btnZip.style.cssText =
      btnBase +
      ';background:linear-gradient(180deg,rgba(59,130,246,.35),rgba(30,58,138,.5))';

    const btnPdf = document.createElement('button');
    btnPdf.type = 'button';
    btnPdf.title = 'Download all pages as one PDF';
    btnPdf.textContent = 'PDF';
    btnPdf.style.cssText =
      btnBase +
      ';background:linear-gradient(180deg,rgba(244,63,94,.28),rgba(136,19,55,.45))';

    const statusWrap = document.createElement('div');
    statusWrap.style.cssText = [
      'display:none',
      'align-items:flex-start',
      'gap:4px',
      'padding:0 8px 8px',
      'border-top:1px solid rgba(148,163,184,.1)',
    ].join(';');

    const status = document.createElement('div');
    status.style.cssText = [
      'flex:1',
      'min-width:0',
      'font-size:11px',
      'line-height:1.4',
      'color:#cbd5e1',
      'word-break:break-word',
    ].join(';');

    const btnDismissStatus = document.createElement('button');
    btnDismissStatus.type = 'button';
    btnDismissStatus.setAttribute('aria-label', 'Dismiss message');
    btnDismissStatus.textContent = '×';
    btnDismissStatus.style.cssText = [
      'flex-shrink:0',
      'width:18px',
      'height:18px',
      'padding:0',
      'border:none',
      'border-radius:4px',
      'background:transparent',
      'color:#64748b',
      'font-size:14px',
      'line-height:1',
      'cursor:pointer',
      'margin-top:-1px',
    ].join(';');
    btnDismissStatus.addEventListener('click', () => {
      statusWrap.style.display = 'none';
    });
    btnDismissStatus.addEventListener('mouseenter', () => {
      btnDismissStatus.style.background = 'rgba(255,255,255,.06)';
      btnDismissStatus.style.color = '#94a3b8';
    });
    btnDismissStatus.addEventListener('mouseleave', () => {
      btnDismissStatus.style.background = 'transparent';
      btnDismissStatus.style.color = '#64748b';
    });

    const restore = document.createElement('button');
    restore.type = 'button';
    restore.setAttribute('aria-label', 'Show download panel');
    restore.textContent = 'Export';
    restore.style.cssText = [
      'display:none',
      'position:fixed',
      'top:10px',
      'right:10px',
      'z-index:2147483646',
      'padding:5px 10px',
      'border-radius:999px',
      'border:1px solid rgba(148,163,184,.35)',
      'background:rgba(15,23,42,.9)',
      'backdrop-filter:blur(8px)',
      'color:#e2e8f0',
      'font-family:inherit',
      'font-size:11px',
      'font-weight:600',
      'letter-spacing:.03em',
      'cursor:pointer',
      'box-shadow:0 2px 12px rgba(0,0,0,.2)',
    ].join(';');

    header.appendChild(title);
    header.appendChild(btnDismissPanel);
    actions.appendChild(btnZip);
    actions.appendChild(btnPdf);
    statusWrap.appendChild(status);
    statusWrap.appendChild(btnDismissStatus);
    panel.appendChild(header);
    panel.appendChild(actions);

    if (DONATION_URL) {
      const donateRow = document.createElement('div');
      donateRow.style.cssText = 'padding:0 8px 6px;text-align:center';
      const donateLink = document.createElement('a');
      donateLink.href = DONATION_URL;
      donateLink.target = '_blank';
      donateLink.rel = 'noopener noreferrer';
      donateLink.textContent = 'Sponsor on GitHub';
      donateLink.title = 'Optional: support maintenance via GitHub Sponsors (opens in a new tab)';
      donateLink.style.cssText = [
        'font-size:10px',
        'font-weight:500',
        'color:#64748b',
        'text-decoration:none',
        'border-bottom:1px solid transparent',
      ].join(';');
      donateLink.addEventListener('mouseenter', () => {
        donateLink.style.color = '#a5b4fc';
        donateLink.style.borderBottomColor = 'rgba(165,180,252,.45)';
      });
      donateLink.addEventListener('mouseleave', () => {
        donateLink.style.color = '#64748b';
        donateLink.style.borderBottomColor = 'transparent';
      });
      donateRow.appendChild(donateLink);
      panel.appendChild(donateRow);
    }

    panel.appendChild(statusWrap);
    root.appendChild(panel);

    btnDismissPanel.addEventListener('click', () => {
      root.style.display = 'none';
      restore.style.display = 'block';
    });
    restore.addEventListener('click', () => {
      root.style.display = '';
      restore.style.display = 'none';
    });

    document.body.appendChild(root);
    document.body.appendChild(restore);

    function showStatus(msg) {
      status.textContent = msg;
      statusWrap.style.display = 'flex';
    }

    /**
     * @param {'zip' | 'pdf'} format
     */
    async function runDownload(format) {
      const params = new URLSearchParams(location.search);
      const cgi = params.get('cgi');
      const param = params.get('param');
      if (!cgi || !param) {
        logError('missing URL params', new Error('cgi or param absent'), {
          hasCgi: !!cgi,
          hasParam: !!param,
          href: location.href.slice(0, 200),
        });
        showStatus('Missing cgi or param in page URL.');
        return;
      }

      btnZip.disabled = true;
      btnPdf.disabled = true;
      showStatus('Starting…');

      const timeBase = Date.now();
      let doneCount = 0;

      try {
        const faceText = await fetchText(cgi, param, 7, 'face.xml', timeBase);
        const { totalPage, gridW, gridH } = parseFaceXml(faceText);

        const tasks = [];
        for (let page = 0; page < totalPage; page++) {
          const p = page;
          tasks.push(async () => {
            try {
              const result = await downloadAndRenderPage(
                p,
                cgi,
                param,
                timeBase,
                gridW,
                gridH
              );
              doneCount += 1;
              status.textContent = `Downloaded ${doneCount} / ${totalPage} pages…`;
              return result;
            } catch (err) {
              logError('page task failed', err, {
                pageIndex: p,
                pageXml: `${String(p).padStart(4, '0')}.xml`,
              });
              throw err;
            }
          });
        }

        showStatus(`Fetching pages (up to ${CONCURRENCY} at once)…`);
        const pageResults = await withConcurrency(CONCURRENCY, tasks);

        if (format === 'zip') {
          showStatus('Building ZIP…');
          const zip = new ZipCtor();
          const folder = zip.folder('pages');
          for (const pr of pageResults) {
            for (let pi = 0; pi < pr.canvases.length; pi++) {
              const canvas = pr.canvases[pi];
              const pngBlob = await new Promise((resolve, reject) => {
                canvas.toBlob(
                  (b) => (b ? resolve(b) : reject(new Error('toBlob failed'))),
                  'image/png'
                );
              });
              folder.file(zipNameForPage(pr, pi), pngBlob);
            }
          }
          const zipBlob = await zip.generateAsync({
            type: 'blob',
            compression: 'DEFLATE',
          });
          const safeName = `bsreader_book_${new Date().toISOString().slice(0, 10)}.zip`;
          saveAs(zipBlob, safeName);
          showStatus(`Done: ${totalPage} page(s) → ${safeName}`);
        } else {
          showStatus('Loading PDF library…');
          const pdfBlob = await buildPdfFromCanvases(pageResults);
          const safeName = `bsreader_book_${new Date().toISOString().slice(0, 10)}.pdf`;
          saveAs(pdfBlob, safeName);
          showStatus(`Done: ${totalPage} page(s) → ${safeName}`);
        }
      } catch (e) {
        logError(`runDownload (${format})`, e, {
          format,
          timeBase,
          href: location.href.slice(0, 160),
        });
        showStatus(`Error: ${e && e.message ? e.message : String(e)}`);
      } finally {
        btnZip.disabled = false;
        btnPdf.disabled = false;
      }
    }

    btnZip.addEventListener('click', () => runDownload('zip'));
    btnPdf.addEventListener('click', () => runDownload('pdf'));
  }

  if (document.readyState === 'loading') {
    document.addEventListener('DOMContentLoaded', injectUi);
  } else {
    injectUi();
  }
})();