BSReader Book Downloader

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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