Pixiv Downloader

下载 Pixiv 插画、漫画、小说和完整小说系列。

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

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

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

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

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

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Pixiv Downloader
// @namespace    https://www.pixiv.net/
// @version      1.0.0
// @description  下载 Pixiv 插画、漫画、小说和完整小说系列。
// @author       Local
// @match        https://www.pixiv.net/*
// @run-at       document-idle
// @require      https://cdn.jsdelivr.net/npm/[email protected]/dist/jszip.min.js
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_notification
// @connect      i.pximg.net
// ==/UserScript==

(() => {
  'use strict';

  const SCRIPT_NAME = 'PixivDownloader';
  const SCRIPT_VERSION = '1.0.0';
  const VERIFIED_DATE = '2026-08-08';
  const TEST_MODE = typeof globalThis !== 'undefined' && Boolean(globalThis.__PIXIV_DOWNLOADER_TEST__);

  const Config = Object.freeze({
    settingsKey: 'pixiv-downloader-settings-v1',
    routePollIntervalMs: 1500,
    maxSeriesPages: 1000,
    seriesPageSize: 30,
    seriesRequestConcurrency: 2,
    seriesRequestIntervalMs: 350,
    embeddedResolveBatchSize: 20,
    defaultSettings: Object.freeze({
      debug: false,
      imageConcurrency: 3,
      retryCount: 3,
      requestTimeoutMs: 30000,
      novelFormat: 'txt',
      seriesFormat: 'both',
      downloadEmbeddedImages: false,
      notifications: true,
      showFloatingButton: true,
      imageFilenameTemplate: '{author} - {title} - {id}',
      novelFilenameTemplate: '{author} - {title} - {id}',
      seriesFilenameTemplate: '{author} - {series} - {seriesId}'
    })
  });

  class PixivDownloaderError extends Error {
    constructor(code, message, options = {}) {
      super(message);
      this.name = 'PixivDownloaderError';
      this.code = code;
      this.status = options.status || 0;
      this.retryable = Boolean(options.retryable);
      this.retryAfterMs = Number(options.retryAfterMs) || 0;
      this.context = options.context || '';
      if (options.cause) this.cause = options.cause;
    }
  }

  const ErrorUtils = {
    abortError(message = '用户已取消下载') {
      if (typeof DOMException === 'function') return new DOMException(message, 'AbortError');
      const error = new Error(message);
      error.name = 'AbortError';
      return error;
    },

    isAbort(error) {
      return Boolean(error && error.name === 'AbortError');
    },

    toUserMessage(error) {
      if (this.isAbort(error)) return '下载已取消。';
      const messages = {
        LOGIN_REQUIRED: '当前内容需要登录后访问,请先在 Pixiv 正常登录。',
        FORBIDDEN: 'Pixiv 拒绝了请求。请确认当前账号有权查看该内容。',
        NOT_FOUND: '作品不存在、已删除,或当前账号无法访问。',
        RATE_LIMIT: 'Pixiv 返回 429,下载速度已受限;请稍后重试。',
        SERVER_ERROR: 'Pixiv 服务器暂时异常,请稍后重试。',
        NETWORK: '网络请求失败,请检查网络连接后重试。',
        TIMEOUT: '请求超时。可在设置中延长超时时间后重试。',
        INVALID_JSON: 'Pixiv 返回了无法解析的数据,网站接口可能已改变。',
        API_ERROR: 'Pixiv 数据接口返回错误。',
        API_SCHEMA_CHANGED: 'Pixiv 返回的数据结构与已验证版本不一致,请检查 PixivDataAdapter。',
        ORIGINAL_UNAVAILABLE: 'Original 原图地址不可用;脚本不会静默降级到压缩图。',
        SERIES_INCOMPLETE: '未能取得完整系列目录,已停止以避免生成缺章文件。',
        ZIP_UNAVAILABLE: 'JSZip 未加载,无法生成 ZIP。',
        ZIP_FAILED: 'ZIP 生成失败,可能是浏览器内存不足。',
        DOWNLOAD_FAILED: '浏览器未能保存下载文件。',
        UNSUPPORTED_IMAGE_HOST: '图片地址不在已授权的 i.pximg.net 域名。',
        INVALID_ID: '页面中的作品 ID 无效。',
        CONTENT_UNAVAILABLE: '作品正文或资源当前不可用。'
      };
      if (error instanceof PixivDownloaderError) {
        const base = messages[error.code] || error.message || '下载失败。';
        return error.context ? `${base}(${error.context})` : base;
      }
      return error && error.message ? error.message : '发生未知错误。';
    },

    serializable(error) {
      return {
        name: error && error.name ? String(error.name) : 'Error',
        code: error && error.code ? String(error.code) : '',
        status: error && error.status ? Number(error.status) : 0,
        message: error && error.message ? String(error.message) : '未知错误'
      };
    }
  };

  const Utils = {
    clamp(value, min, max) {
      const number = Number(value);
      if (!Number.isFinite(number)) return min;
      return Math.min(max, Math.max(min, number));
    },

    sleep(ms, signal) {
      if (signal && signal.aborted) return Promise.reject(ErrorUtils.abortError());
      return new Promise((resolve, reject) => {
        const timer = setTimeout(() => {
          cleanup();
          resolve();
        }, Math.max(0, ms));
        const onAbort = () => {
          clearTimeout(timer);
          cleanup();
          reject(ErrorUtils.abortError());
        };
        const cleanup = () => {
          if (signal) signal.removeEventListener('abort', onAbort);
        };
        if (signal) signal.addEventListener('abort', onAbort, { once: true });
      });
    },

    normalizeLineEndings(value) {
      return String(value == null ? '' : value).replace(/\r\n?/g, '\n');
    },

    toCrlf(value) {
      return this.normalizeLineEndings(value).replace(/\n/g, '\r\n');
    },

    htmlToText(html) {
      const source = String(html == null ? '' : html)
        .replace(/<br\s*\/?>/gi, '\n')
        .replace(/<\/p\s*>/gi, '\n\n');
      if (typeof document !== 'undefined' && document.createElement) {
        const template = document.createElement('template');
        template.innerHTML = source;
        return (template.content.textContent || '').replace(/\u00a0/g, ' ').trim();
      }
      return source
        .replace(/<[^>]*>/g, '')
        .replace(/&lt;/g, '<')
        .replace(/&gt;/g, '>')
        .replace(/&quot;/g, '"')
        .replace(/&#39;/g, "'")
        .replace(/&amp;/g, '&')
        .trim();
    },

    formatBytes(bytes) {
      const value = Number(bytes) || 0;
      if (value < 1024) return `${value} B`;
      const units = ['KB', 'MB', 'GB', 'TB'];
      let size = value;
      let index = -1;
      do {
        size /= 1024;
        index += 1;
      } while (size >= 1024 && index < units.length - 1);
      return `${size >= 100 ? size.toFixed(0) : size.toFixed(1)} ${units[index]}`;
    },

    textByteLength(value) {
      if (typeof TextEncoder === 'function') return new TextEncoder().encode(String(value)).byteLength;
      return unescape(encodeURIComponent(String(value))).length;
    },

    formatDate(value) {
      if (!value) return '';
      const date = new Date(value);
      if (Number.isNaN(date.getTime())) return String(value);
      const pad = (number) => String(number).padStart(2, '0');
      return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())} ${pad(date.getHours())}:${pad(date.getMinutes())}:${pad(date.getSeconds())}`;
    },

    escapeMarkdownText(value) {
      return String(value == null ? '' : value).replace(/([\\`*_[\]<>])/g, '\\$1');
    },

    saveBlob(blob, filename) {
      if (typeof document === 'undefined' || typeof URL === 'undefined' || !URL.createObjectURL) {
        throw new PixivDownloaderError('DOWNLOAD_FAILED', '当前环境不支持浏览器下载。');
      }
      const url = URL.createObjectURL(blob);
      const anchor = document.createElement('a');
      anchor.href = url;
      anchor.download = filename;
      anchor.style.display = 'none';
      document.body.appendChild(anchor);
      try {
        anchor.click();
      } catch (error) {
        URL.revokeObjectURL(url);
        anchor.remove();
        throw new PixivDownloaderError('DOWNLOAD_FAILED', '浏览器拒绝了下载。', { cause: error });
      }
      anchor.remove();
      setTimeout(() => URL.revokeObjectURL(url), 60000);
    },

    uniqueBy(items, keySelector) {
      const seen = new Set();
      const result = [];
      for (const item of items) {
        const key = keySelector(item);
        if (seen.has(key)) continue;
        seen.add(key);
        result.push(item);
      }
      return result;
    },

    assertNotAborted(signal) {
      if (signal && signal.aborted) throw ErrorUtils.abortError();
    }
  };

  const FilenameUtils = {
    cleanUnicode(value) {
      let output = '';
      const input = String(value == null ? '' : value);
      for (let index = 0; index < input.length; index += 1) {
        const code = input.charCodeAt(index);
        if (code >= 0xd800 && code <= 0xdbff) {
          const next = input.charCodeAt(index + 1);
          if (next >= 0xdc00 && next <= 0xdfff) {
            output += input[index] + input[index + 1];
            index += 1;
          } else {
            output += '\ufffd';
          }
        } else if (code >= 0xdc00 && code <= 0xdfff) {
          output += '\ufffd';
        } else {
          output += input[index];
        }
      }
      return output;
    },

    truncate(value, maxLength) {
      const points = Array.from(String(value));
      return points.length <= maxLength ? points.join('') : points.slice(0, maxLength).join('');
    },

    sanitizeFilename(value, options = {}) {
      const maxLength = Utils.clamp(options.maxLength == null ? 160 : options.maxLength, 16, 220);
      const fallback = String(options.fallback || 'untitled');
      let result = this.cleanUnicode(value);
      if (typeof result.normalize === 'function') result = result.normalize('NFC');
      result = result
        .replace(/[\x00-\x1f\x7f]/g, ' ')
        .replace(/[\\/:*?"<>|]/g, '_')
        .replace(/\s+/g, ' ')
        .trim()
        .replace(/[. ]+$/g, '');
      if (!result) result = fallback;
      if (/^(con|prn|aux|nul|com[1-9]|lpt[1-9])(?:\..*)?$/i.test(result)) result = `_${result}`;
      result = this.truncate(result, maxLength).replace(/[. ]+$/g, '');
      return result || fallback;
    },

    formatFilename(template, metadata, options = {}) {
      const requiredToken = options.requiredToken || 'id';
      const requiredValue = String(options.requiredValue == null ? metadata[requiredToken] || '' : options.requiredValue);
      const maxLength = options.maxLength || 160;
      const sourceTemplate = String(template || `{title} - {${requiredToken}}`);
      let rendered = sourceTemplate.replace(/\{([a-zA-Z][a-zA-Z0-9]*)\}/g, (match, key) => {
        return Object.prototype.hasOwnProperty.call(metadata, key) ? String(metadata[key] == null ? '' : metadata[key]) : match;
      });
      if (!sourceTemplate.includes(`{${requiredToken}}`) && requiredValue) rendered += ` - ${requiredValue}`;
      let safe = this.sanitizeFilename(rendered, { maxLength });
      if (requiredValue && !safe.includes(requiredValue)) {
        const suffix = ` - ${this.sanitizeFilename(requiredValue, { maxLength: 40 })}`;
        const prefixLength = Math.max(1, maxLength - Array.from(suffix).length);
        safe = `${this.truncate(safe, prefixLength).replace(/[. ]+$/g, '')}${suffix}`;
      }
      return safe;
    },

    getNumberPadding(totalCount, minimum = 2) {
      return Math.max(minimum, String(Math.max(1, Number(totalCount) || 1)).length);
    },

    extensionFromUrl(url, fallback = 'bin') {
      try {
        const match = new URL(url).pathname.match(/\.([a-zA-Z0-9]{1,8})$/);
        if (!match) return fallback;
        const extension = match[1].toLowerCase();
        return extension === 'jpeg' ? 'jpg' : extension;
      } catch (_) {
        return fallback;
      }
    },

    extensionFromContentType(contentType) {
      const type = String(contentType || '').split(';')[0].trim().toLowerCase();
      const map = {
        'image/jpeg': 'jpg',
        'image/png': 'png',
        'image/gif': 'gif',
        'image/webp': 'webp',
        'image/avif': 'avif',
        'application/zip': 'zip',
        'application/x-zip-compressed': 'zip'
      };
      return map[type] || '';
    },

    chooseExtension(url, contentType, fallback = 'bin') {
      return this.extensionFromContentType(contentType) || this.extensionFromUrl(url, fallback);
    }
  };

  class Logger {
    constructor(enabled = false) {
      this.enabled = Boolean(enabled);
    }

    setEnabled(enabled) {
      this.enabled = Boolean(enabled);
    }

    info(message, data) {
      if (!this.enabled) return;
      if (data === undefined) console.info(`[${SCRIPT_NAME}][INFO] ${message}`);
      else console.info(`[${SCRIPT_NAME}][INFO] ${message}`, data);
    }

    warn(message, data) {
      if (!this.enabled) return;
      if (data === undefined) console.warn(`[${SCRIPT_NAME}][WARN] ${message}`);
      else console.warn(`[${SCRIPT_NAME}][WARN] ${message}`, data);
    }

    error(message, error) {
      const safe = error ? ErrorUtils.serializable(error) : undefined;
      if (safe === undefined) console.error(`[${SCRIPT_NAME}][ERROR] ${message}`);
      else console.error(`[${SCRIPT_NAME}][ERROR] ${message}`, safe);
    }
  }

  class SettingsManager {
    constructor() {
      this.settings = { ...Config.defaultSettings };
      this.listeners = new Set();
    }

    load() {
      let stored = {};
      try {
        if (typeof GM_getValue === 'function') stored = GM_getValue(Config.settingsKey, {}) || {};
      } catch (_) {
        stored = {};
      }
      this.settings = this.validate({ ...Config.defaultSettings, ...stored });
      return this.get();
    }

    get() {
      return { ...this.settings };
    }

    validate(input) {
      const novelFormats = new Set(['txt', 'markdown', 'both']);
      const seriesFormats = new Set(['merged-txt', 'chapter-zip', 'both']);
      return {
        debug: Boolean(input.debug),
        imageConcurrency: Math.round(Utils.clamp(input.imageConcurrency, 1, 5)),
        retryCount: Math.round(Utils.clamp(input.retryCount, 0, 5)),
        requestTimeoutMs: Math.round(Utils.clamp(input.requestTimeoutMs, 10000, 120000)),
        novelFormat: novelFormats.has(input.novelFormat) ? input.novelFormat : Config.defaultSettings.novelFormat,
        seriesFormat: seriesFormats.has(input.seriesFormat) ? input.seriesFormat : Config.defaultSettings.seriesFormat,
        downloadEmbeddedImages: Boolean(input.downloadEmbeddedImages),
        notifications: Boolean(input.notifications),
        showFloatingButton: Boolean(input.showFloatingButton),
        imageFilenameTemplate: String(input.imageFilenameTemplate || Config.defaultSettings.imageFilenameTemplate).slice(0, 240),
        novelFilenameTemplate: String(input.novelFilenameTemplate || Config.defaultSettings.novelFilenameTemplate).slice(0, 240),
        seriesFilenameTemplate: String(input.seriesFilenameTemplate || Config.defaultSettings.seriesFilenameTemplate).slice(0, 240)
      };
    }

    save(nextSettings) {
      this.settings = this.validate({ ...this.settings, ...nextSettings });
      if (typeof GM_setValue === 'function') GM_setValue(Config.settingsKey, this.settings);
      for (const listener of this.listeners) listener(this.get());
      return this.get();
    }

    subscribe(listener) {
      this.listeners.add(listener);
      return () => this.listeners.delete(listener);
    }
  }

  const PageDetector = {
    detect(input) {
      let url;
      try {
        url = input instanceof URL ? input : new URL(String(input), 'https://www.pixiv.net/');
      } catch (_) {
        return { type: 'other', key: 'other' };
      }
      const path = url.pathname.replace(/^\/(?:[a-z]{2}(?:-[a-z]{2})?)\//i, '/');
      let match = path.match(/^\/artworks\/(\d+)(?:\/|$)/);
      if (match) return { type: 'artwork', id: match[1], key: `artwork:${match[1]}` };

      match = path.match(/^\/novel\/series\/(\d+)\/contents\/(\d+)(?:\/|$)/);
      if (match) {
        return {
          type: 'novel-series-content',
          seriesId: match[1],
          contentOrder: Number(match[2]),
          key: `novel-series-content:${match[1]}:${match[2]}`
        };
      }

      match = path.match(/^\/novel\/series\/(\d+)(?:\/|$)/);
      if (match) return { type: 'novel-series', seriesId: match[1], key: `novel-series:${match[1]}` };

      if (path === '/novel/show.php') {
        const id = url.searchParams.get('id');
        if (/^\d+$/.test(id || '')) return { type: 'novel', id, key: `novel:${id}` };
      }
      return { type: 'other', key: 'other' };
    }
  };

  class RouteObserver {
    constructor(options = {}) {
      this.pollIntervalMs = options.pollIntervalMs || Config.routePollIntervalMs;
      this.callback = null;
      this.lastHref = '';
      this.timer = null;
      this.pollTimer = null;
      this.eventName = `${SCRIPT_NAME}:location-change`;
      this.originalPushState = null;
      this.originalReplaceState = null;
      this.boundCheck = () => this.scheduleCheck();
    }

    start(callback) {
      if (this.callback || typeof window === 'undefined') return;
      this.callback = callback;
      this.lastHref = window.location.href;
      this.originalPushState = history.pushState;
      this.originalReplaceState = history.replaceState;
      const dispatch = () => window.dispatchEvent(new Event(this.eventName));
      history.pushState = (...args) => {
        const result = this.originalPushState.apply(history, args);
        dispatch();
        return result;
      };
      history.replaceState = (...args) => {
        const result = this.originalReplaceState.apply(history, args);
        dispatch();
        return result;
      };
      window.addEventListener(this.eventName, this.boundCheck);
      window.addEventListener('popstate', this.boundCheck);
      this.pollTimer = setInterval(this.boundCheck, this.pollIntervalMs);
      callback(PageDetector.detect(window.location.href));
    }

    scheduleCheck() {
      clearTimeout(this.timer);
      this.timer = setTimeout(() => this.check(), 60);
    }

    check() {
      if (!this.callback || window.location.href === this.lastHref) return;
      this.lastHref = window.location.href;
      this.callback(PageDetector.detect(this.lastHref));
    }

    stop() {
      if (typeof window === 'undefined') return;
      clearTimeout(this.timer);
      clearInterval(this.pollTimer);
      window.removeEventListener(this.eventName, this.boundCheck);
      window.removeEventListener('popstate', this.boundCheck);
      if (this.originalPushState) history.pushState = this.originalPushState;
      if (this.originalReplaceState) history.replaceState = this.originalReplaceState;
      this.callback = null;
    }
  }

  function parseRetryAfter(value) {
    if (!value) return 0;
    const seconds = Number(value);
    if (Number.isFinite(seconds)) return Math.max(0, seconds * 1000);
    const date = new Date(value).getTime();
    return Number.isFinite(date) ? Math.max(0, date - Date.now()) : 0;
  }

  function mapHttpStatus(status, context = '', retryAfterMs = 0) {
    const options = { status, context, retryAfterMs };
    if (status === 401) return new PixivDownloaderError('LOGIN_REQUIRED', '需要登录。', options);
    if (status === 403) return new PixivDownloaderError('FORBIDDEN', '请求被拒绝。', options);
    if (status === 404) return new PixivDownloaderError('NOT_FOUND', '资源不存在。', options);
    if (status === 429) return new PixivDownloaderError('RATE_LIMIT', '请求过于频繁。', { ...options, retryable: true });
    if (status >= 500) return new PixivDownloaderError('SERVER_ERROR', `服务器返回 ${status}。`, { ...options, retryable: true });
    return new PixivDownloaderError('API_ERROR', `请求返回 HTTP ${status}。`, options);
  }

  class RequestManager {
    constructor(options = {}) {
      this.baseUrl = options.baseUrl || 'https://www.pixiv.net';
      this.timeoutProvider = options.timeoutProvider || (() => Config.defaultSettings.requestTimeoutMs);
      this.retryProvider = options.retryProvider || (() => 0);
      this.retryBaseDelayMs = Math.max(0, Number(options.retryBaseDelayMs == null ? 1000 : options.retryBaseDelayMs));
      this.fetchImpl = options.fetchImpl || (typeof fetch === 'function' ? fetch.bind(globalThis) : null);
      this.logger = options.logger || new Logger(false);
    }

    async requestJson(path, options = {}) {
      const retryCount = Math.round(Utils.clamp(
        options.retries == null ? this.retryProvider() : options.retries,
        0,
        5
      ));
      let lastError = null;
      for (let attempt = 0; attempt <= retryCount; attempt += 1) {
        try {
          return await this.requestJsonOnce(path, options);
        } catch (error) {
          lastError = error;
          if (ErrorUtils.isAbort(error) || !error.retryable || attempt >= retryCount) throw error;
          const exponential = Math.min(30000, this.retryBaseDelayMs * (2 ** attempt));
          const jitter = exponential * 0.2 * ((Math.random() * 2) - 1);
          let delay = Math.max(0, Math.round(exponential + jitter));
          if (error.code === 'RATE_LIMIT') delay = Math.max(delay, error.retryAfterMs || 10000);
          this.logger.warn('Pixiv JSON 请求等待重试', {
            path: new URL(path, this.baseUrl).pathname,
            attempt: attempt + 1,
            delay
          });
          await Utils.sleep(delay, options.signal);
        }
      }
      throw lastError;
    }

    async requestJsonOnce(path, options = {}) {
      if (!this.fetchImpl) throw new PixivDownloaderError('NETWORK', 'Fetch API 不可用。');
      const url = new URL(path, this.baseUrl).toString();
      const externalSignal = options.signal;
      Utils.assertNotAborted(externalSignal);
      const controller = new AbortController();
      const timeoutMs = Number(options.timeoutMs) || Number(this.timeoutProvider()) || Config.defaultSettings.requestTimeoutMs;
      let timedOut = false;
      const onExternalAbort = () => controller.abort();
      if (externalSignal) externalSignal.addEventListener('abort', onExternalAbort, { once: true });
      const timer = setTimeout(() => {
        timedOut = true;
        controller.abort();
      }, timeoutMs);
      this.logger.info('请求 Pixiv JSON', { path: new URL(url).pathname });
      try {
        const response = await this.fetchImpl(url, {
          method: 'GET',
          credentials: 'include',
          cache: 'no-store',
          headers: { Accept: 'application/json' },
          signal: controller.signal
        });
        const status = Number(response.status) || 0;
        if (!(status >= 200 && status < 300)) {
          const retryAfter = response.headers && response.headers.get ? parseRetryAfter(response.headers.get('Retry-After')) : 0;
          throw mapHttpStatus(status, new URL(url).pathname, retryAfter);
        }
        const text = await response.text();
        try {
          return JSON.parse(text);
        } catch (error) {
          throw new PixivDownloaderError('INVALID_JSON', 'JSON 解析失败。', {
            context: new URL(url).pathname,
            cause: error
          });
        }
      } catch (error) {
        if (ErrorUtils.isAbort(error)) {
          if (externalSignal && externalSignal.aborted) throw ErrorUtils.abortError();
          if (timedOut) {
            throw new PixivDownloaderError('TIMEOUT', '请求超时。', {
              retryable: true,
              context: new URL(url).pathname,
              cause: error
            });
          }
          throw ErrorUtils.abortError();
        }
        if (error instanceof PixivDownloaderError) throw error;
        throw new PixivDownloaderError('NETWORK', '网络请求失败。', {
          retryable: true,
          context: new URL(url).pathname,
          cause: error
        });
      } finally {
        clearTimeout(timer);
        if (externalSignal) externalSignal.removeEventListener('abort', onExternalAbort);
      }
    }

    async getBody(path, options = {}) {
      const payload = await this.requestJson(path, options);
      if (!payload || typeof payload !== 'object') {
        throw new PixivDownloaderError('API_SCHEMA_CHANGED', '响应不是对象。', { context: path });
      }
      if (payload.error) {
        const message = String(payload.message || 'Pixiv API error');
        const lower = message.toLowerCase();
        if (lower.includes('login')) throw new PixivDownloaderError('LOGIN_REQUIRED', message, { context: path });
        if (lower.includes('not found') || lower.includes('deleted')) {
          throw new PixivDownloaderError('NOT_FOUND', message, { context: path });
        }
        throw new PixivDownloaderError('API_ERROR', message, { context: path });
      }
      if (!Object.prototype.hasOwnProperty.call(payload, 'body') || payload.body == null) {
        throw new PixivDownloaderError('API_SCHEMA_CHANGED', '响应缺少 body。', { context: path });
      }
      return payload.body;
    }
  }

  // Pixiv adapter boundary. Verified against the live site on 2026-08-08.
  // If Pixiv changes its internal JSON, update this class before touching downloaders or UI code.
  class PixivDataAdapter {
    constructor(requestManager, logger = new Logger(false)) {
      this.request = requestManager;
      this.logger = logger;
      this.cache = new Map();
    }

    normalizeId(value, label = 'ID') {
      const id = String(value == null ? '' : value);
      if (!/^\d+$/.test(id)) throw new PixivDownloaderError('INVALID_ID', `${label} 无效。`);
      return id;
    }

    memo(key, loader, force = false) {
      if (!force && this.cache.has(key)) return this.cache.get(key);
      const promise = Promise.resolve().then(loader).catch((error) => {
        this.cache.delete(key);
        throw error;
      });
      this.cache.set(key, promise);
      return promise;
    }

    async getArtworkInfo(id, options = {}) {
      const artworkId = this.normalizeId(id, '作品 ID');
      return this.memo(`artwork:${artworkId}`, async () => {
        const raw = await this.request.getBody(`/ajax/illust/${artworkId}`, options);
        const typeNumber = Number(raw.illustType);
        const type = typeNumber === 2 ? 'ugoira' : typeNumber === 1 ? 'manga' : 'illustration';
        if (!raw.illustTitle || !raw.userId || !raw.userName || !Number.isFinite(Number(raw.pageCount))) {
          throw new PixivDownloaderError('API_SCHEMA_CHANGED', '插画基本字段缺失。', {
            context: 'PixivDataAdapter.getArtworkInfo'
          });
        }
        return {
          id: String(raw.illustId || raw.id || artworkId),
          title: String(raw.illustTitle || raw.title),
          description: Utils.htmlToText(raw.illustComment || raw.description || ''),
          author: String(raw.userName),
          authorId: String(raw.userId),
          pageCount: Number(raw.pageCount),
          type,
          typeNumber,
          width: Number(raw.width) || 0,
          height: Number(raw.height) || 0,
          createDate: raw.createDate || '',
          updateDate: raw.uploadDate || '',
          tags: this.mapTags(raw.tags),
          isLoginOnly: Boolean(raw.isLoginOnly),
          firstOriginalUrl: raw.urls && raw.urls.original ? String(raw.urls.original) : ''
        };
      }, options.force);
    }

    async getArtworkPages(id, options = {}) {
      const artworkId = this.normalizeId(id, '作品 ID');
      return this.memo(`artwork-pages:${artworkId}`, async () => {
        const body = await this.request.getBody(`/ajax/illust/${artworkId}/pages`, options);
        if (!Array.isArray(body)) {
          throw new PixivDownloaderError('API_SCHEMA_CHANGED', '插画 pages 不是数组。', {
            context: 'PixivDataAdapter.getArtworkPages'
          });
        }
        return body.map((page, index) => ({
          index,
          originalUrl: page && page.urls && page.urls.original ? String(page.urls.original) : '',
          extension: page && page.urls && page.urls.original ? FilenameUtils.extensionFromUrl(page.urls.original, 'bin') : '',
          width: Number(page && page.width) || 0,
          height: Number(page && page.height) || 0
        }));
      }, options.force);
    }

    async getUgoiraMeta(id, options = {}) {
      const artworkId = this.normalizeId(id, '作品 ID');
      return this.memo(`ugoira:${artworkId}`, async () => {
        const raw = await this.request.getBody(`/ajax/illust/${artworkId}/ugoira_meta`, options);
        if (!raw.originalSrc || !Array.isArray(raw.frames)) {
          throw new PixivDownloaderError('API_SCHEMA_CHANGED', 'Ugoira 元数据缺失。', {
            context: 'PixivDataAdapter.getUgoiraMeta'
          });
        }
        return {
          originalSrc: String(raw.originalSrc),
          previewSrc: raw.src ? String(raw.src) : '',
          mimeType: raw.mime_type ? String(raw.mime_type) : 'application/zip',
          frames: raw.frames.map((frame, index) => ({
            index,
            file: String(frame.file || ''),
            delay: Number(frame.delay) || 0
          }))
        };
      }, options.force);
    }

    async getNovelInfo(id, options = {}) {
      const novelId = this.normalizeId(id, '小说 ID');
      return this.memo(`novel:${novelId}`, async () => {
        const raw = await this.request.getBody(`/ajax/novel/${novelId}`, options);
        return this.mapNovel(raw, 'PixivDataAdapter.getNovelInfo');
      }, options.force);
    }

    async getNovelBySeriesContent(seriesId, contentOrder, options = {}) {
      const normalizedSeriesId = this.normalizeId(seriesId, 'Series ID');
      const order = Number(contentOrder);
      if (!Number.isInteger(order) || order < 1) throw new PixivDownloaderError('INVALID_ID', '章节顺序无效。');
      return this.memo(`novel-series-content:${normalizedSeriesId}:${order}`, async () => {
        const raw = await this.request.getBody(`/ajax/novel/series/${normalizedSeriesId}/contents/${order}`, options);
        return this.mapNovel(raw, 'PixivDataAdapter.getNovelBySeriesContent');
      }, options.force);
    }

    mapNovel(raw, context) {
      if (!raw || !raw.id || !raw.title || !raw.userId || !raw.userName || typeof raw.content !== 'string') {
        if (raw && raw.isLoginOnly) {
          throw new PixivDownloaderError('LOGIN_REQUIRED', '登录限定小说正文不可用。', { context });
        }
        throw new PixivDownloaderError('API_SCHEMA_CHANGED', '小说基本字段或正文缺失。', { context });
      }
      const uploadedImages = {};
      if (raw.textEmbeddedImages && typeof raw.textEmbeddedImages === 'object') {
        for (const [key, image] of Object.entries(raw.textEmbeddedImages)) {
          uploadedImages[String(key)] = {
            id: String((image && image.novelImageId) || key),
            originalUrl: image && image.urls && image.urls.original ? String(image.urls.original) : ''
          };
        }
      }
      const series = raw.seriesNavData && raw.seriesNavData.seriesId ? raw.seriesNavData : null;
      return {
        id: String(raw.id),
        title: String(raw.title),
        author: String(raw.userName),
        authorId: String(raw.userId),
        description: Utils.htmlToText(raw.description || ''),
        tags: this.mapTags(raw.tags),
        createDate: raw.createDate || '',
        updateDate: raw.uploadDate || '',
        characterCount: Number(raw.characterCount) || 0,
        wordCount: Number(raw.wordCount) || 0,
        useWordCount: Boolean(raw.useWordCount),
        readingTime: Number(raw.readingTime) || 0,
        text: String(raw.content),
        rawText: String(raw.content),
        seriesId: series ? String(series.seriesId) : '',
        seriesTitle: series ? String(series.title || '') : '',
        seriesOrder: series ? Number(series.order) || 0 : 0,
        uploadedImages,
        isLoginOnly: Boolean(raw.isLoginOnly),
        language: raw.language ? String(raw.language) : ''
      };
    }

    mapTags(rawTags) {
      if (!rawTags) return [];
      const list = Array.isArray(rawTags) ? rawTags : Array.isArray(rawTags.tags) ? rawTags.tags : [];
      return list.map((tag) => String(typeof tag === 'string' ? tag : tag && tag.tag ? tag.tag : '')).filter(Boolean);
    }

    async getNovelSeriesInfo(seriesId, options = {}) {
      const id = this.normalizeId(seriesId, 'Series ID');
      return this.memo(`novel-series:${id}`, async () => {
        const raw = await this.request.getBody(`/ajax/novel/series/${id}`, options);
        if (!raw || !raw.id || !raw.title || !raw.userId || !raw.userName) {
          throw new PixivDownloaderError('API_SCHEMA_CHANGED', '系列基本字段缺失。', {
            context: 'PixivDataAdapter.getNovelSeriesInfo'
          });
        }
        const total = Number(raw.displaySeriesContentCount || raw.publishedContentCount || raw.total) || 0;
        return {
          id: String(raw.id),
          title: String(raw.title),
          author: String(raw.userName),
          authorId: String(raw.userId),
          description: Utils.htmlToText(raw.caption || ''),
          tags: Array.isArray(raw.tags) ? raw.tags.map(String) : [],
          total,
          publishedContentCount: Number(raw.publishedContentCount) || 0,
          displaySeriesContentCount: Number(raw.displaySeriesContentCount) || 0,
          createDate: raw.createDate || raw.createdTimestamp || '',
          updateDate: raw.updateDate || raw.updatedTimestamp || '',
          isConcluded: Boolean(raw.isConcluded),
          language: raw.language ? String(raw.language) : ''
        };
      }, options.force);
    }

    async getNovelSeriesWorks(seriesId, options = {}) {
      const id = this.normalizeId(seriesId, 'Series ID');
      const expectedTotal = Number(options.expectedTotal) || 0;
      try {
        const titles = await this.request.getBody(`/ajax/novel/series/${id}/content_titles`, options);
        const primary = this.validateContentTitles(titles, expectedTotal);
        this.logger.info('系列目录使用 content_titles', { seriesId: id, count: primary.length });
        if (typeof options.onPage === 'function') options.onPage({ source: 'content_titles', count: primary.length, done: true });
        return primary;
      } catch (error) {
        if (ErrorUtils.isAbort(error)) throw error;
        this.logger.warn('content_titles 不可用,改用分页接口', ErrorUtils.serializable(error));
      }
      return this.getNovelSeriesWorksPaginated(id, { ...options, expectedTotal });
    }

    validateContentTitles(body, expectedTotal = 0) {
      if (!Array.isArray(body) || body.length === 0) {
        throw new PixivDownloaderError('API_SCHEMA_CHANGED', 'content_titles 为空或不是数组。', {
          context: 'PixivDataAdapter.getNovelSeriesWorks'
        });
      }
      const works = body.map((item, index) => ({
        index: index + 1,
        novelId: item && item.id ? String(item.id) : '',
        title: item && item.title ? String(item.title) : `第 ${index + 1} 章`,
        available: !item || item.available !== false,
        contentOrder: index + 1
      }));
      const ids = works.map((work) => work.novelId).filter(Boolean);
      if (ids.length !== works.length || new Set(ids).size !== ids.length) {
        throw new PixivDownloaderError('SERIES_INCOMPLETE', 'content_titles 包含空 ID 或重复 ID。');
      }
      if (expectedTotal > 0 && works.length !== expectedTotal) {
        throw new PixivDownloaderError('SERIES_INCOMPLETE', `目录数量 ${works.length} 与系列总数 ${expectedTotal} 不一致。`);
      }
      return works;
    }

    async getNovelSeriesWorksPaginated(seriesId, options = {}) {
      const expectedTotal = Number(options.expectedTotal) || 0;
      const works = [];
      const seenIds = new Set();
      let lastOrder = 0;
      let terminated = false;
      for (let pageIndex = 0; pageIndex < Config.maxSeriesPages; pageIndex += 1) {
        Utils.assertNotAborted(options.signal);
        const path = `/ajax/novel/series_content/${seriesId}?limit=${Config.seriesPageSize}&last_order=${lastOrder}&order_by=asc`;
        const body = await this.request.getBody(path, options);
        const pageWorks = body && body.page && Array.isArray(body.page.seriesContents) ? body.page.seriesContents : null;
        if (!pageWorks) {
          throw new PixivDownloaderError('API_SCHEMA_CHANGED', 'series_content 缺少 page.seriesContents。', {
            context: 'PixivDataAdapter.getNovelSeriesWorks'
          });
        }
        if (pageWorks.length === 0) {
          terminated = true;
          break;
        }
        let added = 0;
        let maxOrder = lastOrder;
        for (const item of pageWorks) {
          const novelId = item && item.id ? String(item.id) : '';
          const contentOrder = Number(item && item.series && item.series.contentOrder);
          if (!novelId || !Number.isInteger(contentOrder) || contentOrder < 1) continue;
          maxOrder = Math.max(maxOrder, contentOrder);
          if (seenIds.has(novelId)) continue;
          seenIds.add(novelId);
          works.push({
            index: contentOrder,
            novelId,
            title: String(item.title || `第 ${contentOrder} 章`),
            available: true,
            contentOrder
          });
          added += 1;
        }
        works.sort((left, right) => left.contentOrder - right.contentOrder);
        if (typeof options.onPage === 'function') {
          options.onPage({ source: 'series_content', page: pageIndex + 1, count: works.length, done: false });
        }
        if (expectedTotal > 0 && works.length >= expectedTotal) {
          terminated = true;
          break;
        }
        if (added === 0 || maxOrder <= lastOrder) {
          throw new PixivDownloaderError('SERIES_INCOMPLETE', '系列分页没有新增章节,已终止以避免无限请求。');
        }
        lastOrder = maxOrder;
        if (pageWorks.length < Config.seriesPageSize) {
          terminated = true;
          break;
        }
      }
      if (!terminated) {
        throw new PixivDownloaderError('SERIES_INCOMPLETE', `系列分页超过安全上限 ${Config.maxSeriesPages}。`);
      }
      if (expectedTotal > 0 && works.length !== expectedTotal) {
        throw new PixivDownloaderError('SERIES_INCOMPLETE', `仅取得 ${works.length}/${expectedTotal} 章。`);
      }
      if (works.length === 0) throw new PixivDownloaderError('SERIES_INCOMPLETE', '系列目录为空。');
      return works.map((work, index) => ({ ...work, index: index + 1 }));
    }

    async resolveNovelEmbeddedImages(novel, options = {}) {
      const references = NovelParser.extractImageReferences(novel.rawText);
      const resolved = new Map();
      const pixivReferences = [];
      for (const reference of references) {
        if (reference.kind === 'uploaded') {
          const uploaded = novel.uploadedImages[reference.imageId];
          resolved.set(reference.key, {
            ...reference,
            available: Boolean(uploaded && uploaded.originalUrl),
            originalUrl: uploaded && uploaded.originalUrl ? uploaded.originalUrl : '',
            unavailableReason: uploaded && uploaded.originalUrl ? '' : '上传图片 Original URL 缺失'
          });
        } else {
          pixivReferences.push(reference);
        }
      }

      for (let offset = 0; offset < pixivReferences.length; offset += Config.embeddedResolveBatchSize) {
        Utils.assertNotAborted(options.signal);
        const batch = pixivReferences.slice(offset, offset + Config.embeddedResolveBatchSize);
        const params = new URLSearchParams();
        for (const reference of batch) params.append('id[]', reference.citationId);
        try {
          const body = await this.request.getBody(`/ajax/novel/${novel.id}/insert_illusts?${params.toString()}`, options);
          for (const reference of batch) {
            const item = body && body[reference.citationId];
            const originalUrl = item && item.visible && item.illust && item.illust.images && item.illust.images.original
              ? String(item.illust.images.original)
              : '';
            resolved.set(reference.key, {
              ...reference,
              available: Boolean(originalUrl),
              originalUrl,
              unavailableReason: originalUrl ? '' : String((item && item.unavailableType) || '引用插画不可用')
            });
          }
        } catch (error) {
          if (ErrorUtils.isAbort(error)) throw error;
          for (const reference of batch) {
            resolved.set(reference.key, {
              ...reference,
              available: false,
              originalUrl: '',
              unavailableReason: ErrorUtils.toUserMessage(error)
            });
          }
        }
      }
      return references.map((reference) => resolved.get(reference.key) || {
        ...reference,
        available: false,
        originalUrl: '',
        unavailableReason: '未能解析图片引用'
      });
    }
  }

  class NovelParser {
    static extractImageReferences(rawText) {
      const source = String(rawText == null ? '' : rawText);
      const references = [];
      const seen = new Set();
      const pattern = /\[(uploadedimage|pixivimage):([^\]]+)\]/g;
      let match;
      while ((match = pattern.exec(source))) {
        if (match[1] === 'uploadedimage') {
          if (!/^\d+$/.test(match[2])) continue;
          const key = `uploaded:${match[2]}`;
          if (seen.has(key)) continue;
          seen.add(key);
          references.push({
            key,
            kind: 'uploaded',
            imageId: match[2],
            raw: match[0]
          });
          continue;
        }
        const parsed = match[2].match(/^(\d+)(?:-(\d+))?$/);
        if (!parsed) continue;
        const citationId = parsed[2] ? `${parsed[1]}-${parsed[2]}` : parsed[1];
        const key = `pixiv:${citationId}`;
        if (seen.has(key)) continue;
        seen.add(key);
        references.push({
          key,
          kind: 'pixiv',
          illustId: parsed[1],
          pageNumber: parsed[2] ? Number(parsed[2]) : 1,
          citationId,
          raw: match[0]
        });
      }
      return references;
    }

    static tokenize(rawText) {
      const source = Utils.normalizeLineEndings(rawText);
      const tokens = [];
      let textBuffer = '';
      const flushText = () => {
        if (!textBuffer) return;
        tokens.push({ type: 'text', value: textBuffer });
        textBuffer = '';
      };
      const addToken = (token) => {
        flushText();
        tokens.push(token);
      };

      for (let index = 0; index < source.length;) {
        if (source[index] !== '[') {
          textBuffer += source[index];
          index += 1;
          continue;
        }

        if (source.startsWith('[newpage]', index)) {
          addToken({ type: 'newpage', raw: '[newpage]' });
          index += '[newpage]'.length;
          continue;
        }

        if (source.startsWith('[[', index)) {
          const end = source.indexOf(']]', index + 2);
          if (end >= 0) {
            const raw = source.slice(index, end + 2);
            const inner = source.slice(index + 2, end);
            const prefixes = [
              ['jumpuri:', 'jumpuri'],
              ['rb:', 'ruby'],
              ['emphasismark:', 'emphasis']
            ];
            let matched = false;
            for (const [prefix, type] of prefixes) {
              if (!inner.startsWith(prefix)) continue;
              const body = inner.slice(prefix.length);
              let divider = body.indexOf(' > ');
              let dividerLength = 3;
              if (divider < 0) {
                divider = body.indexOf('>');
                dividerLength = 1;
              }
              if (divider >= 0) {
                addToken({
                  type,
                  left: body.slice(0, divider).trim(),
                  right: body.slice(divider + dividerLength).trim(),
                  raw
                });
                index = end + 2;
                matched = true;
              }
              break;
            }
            if (matched) continue;
          }
        }

        const end = source.indexOf(']', index + 1);
        if (end >= 0) {
          const raw = source.slice(index, end + 1);
          const inner = source.slice(index + 1, end);
          let match = inner.match(/^chapter:(.*)$/s);
          if (match) {
            addToken({ type: 'chapter', value: match[1], raw });
            index = end + 1;
            continue;
          }
          match = inner.match(/^pixivimage:(\d+)(?:-(\d+))?$/);
          if (match) {
            const citationId = match[2] ? `${match[1]}-${match[2]}` : match[1];
            addToken({
              type: 'pixivimage',
              illustId: match[1],
              pageNumber: match[2] ? Number(match[2]) : 1,
              citationId,
              key: `pixiv:${citationId}`,
              raw
            });
            index = end + 1;
            continue;
          }
          match = inner.match(/^uploadedimage:(\d+)$/);
          if (match) {
            addToken({ type: 'uploadedimage', imageId: match[1], key: `uploaded:${match[1]}`, raw });
            index = end + 1;
            continue;
          }
          match = inner.match(/^jump:(\d+)$/);
          if (match) {
            addToken({ type: 'jump', pageNumber: Number(match[1]), raw });
            index = end + 1;
            continue;
          }
          match = inner.match(/^([bi]):(.*)$/s);
          if (match) {
            addToken({ type: match[1] === 'b' ? 'bold' : 'italic', value: match[2], raw });
            index = end + 1;
            continue;
          }
        }

        textBuffer += source[index];
        index += 1;
      }
      flushText();
      return tokens;
    }

    static render(rawText, mode = 'plain', resourceMap = new Map()) {
      const context = { mode, resourceMap, currentPage: 1 };
      return this.renderTokens(this.tokenize(rawText), context);
    }

    static renderTokens(tokens, context) {
      let output = '';
      for (const token of tokens) {
        switch (token.type) {
          case 'text':
            output += token.value;
            break;
          case 'newpage':
            context.currentPage += 1;
            output += context.mode === 'markdown'
              ? `\n\n<a id="page-${context.currentPage}"></a>\n\n---\n\n`
              : '\n\n==================== 分页 ====================\n\n';
            break;
          case 'chapter':
            output += context.mode === 'markdown'
              ? `\n\n## ${token.value}\n\n`
              : `\n\n【${token.value}】\n\n`;
            break;
          case 'pixivimage':
          case 'uploadedimage':
            output += this.renderImageToken(token, context);
            break;
          case 'jump':
            output += context.mode === 'markdown'
              ? `[跳转到第 ${token.pageNumber} 页](#page-${token.pageNumber})`
              : `(跳转到第 ${token.pageNumber} 页)`;
            break;
          case 'jumpuri': {
            const title = token.left || token.right;
            if (context.mode === 'markdown' && /^https?:\/\//i.test(token.right)) {
              output += `[${Utils.escapeMarkdownText(title)}](${token.right.replace(/\s/g, '%20')})`;
            } else {
              output += token.right ? `${title}(${token.right})` : title;
            }
            break;
          }
          case 'ruby':
            output += context.mode === 'markdown'
              ? `<ruby>${this.escapeHtmlText(token.left)}<rt>${this.escapeHtmlText(token.right)}</rt></ruby>`
              : `${token.left}(${token.right})`;
            break;
          case 'emphasis':
            output += context.mode === 'markdown'
              ? `<span data-emphasis-mark="${this.escapeHtmlAttribute(token.right)}">${this.escapeHtmlText(token.left)}</span>`
              : `${token.left}(强调符号:${token.right})`;
            break;
          case 'bold': {
            const inner = this.renderTokens(this.tokenize(token.value), context);
            output += context.mode === 'markdown' ? `**${inner}**` : inner;
            break;
          }
          case 'italic': {
            const inner = this.renderTokens(this.tokenize(token.value), context);
            output += context.mode === 'markdown' ? `*${inner}*` : inner;
            break;
          }
          default:
            output += token.raw || '';
        }
      }
      return output;
    }

    static renderImageToken(token, context) {
      const resource = context.resourceMap instanceof Map ? context.resourceMap.get(token.key) : null;
      const localPath = typeof resource === 'string' ? resource : resource && resource.localPath;
      const label = token.type === 'pixivimage'
        ? `Pixiv 作品 ${token.illustId}${token.pageNumber > 1 ? ` 第 ${token.pageNumber} 页` : ''}`
        : `上传图片 ${token.imageId}`;
      if (context.mode === 'markdown') {
        if (localPath) return `\n\n![${label}](${localPath})\n\n`;
        if (token.type === 'pixivimage') {
          return `\n\n[插图:${label}](https://www.pixiv.net/artworks/${token.illustId})\n\n`;
        }
        return `\n\n[插图:${label}]\n\n`;
      }
      return `\n\n【插图:${label}】\n\n`;
    }

    static escapeHtmlAttribute(value) {
      return String(value == null ? '' : value)
        .replace(/&/g, '&amp;')
        .replace(/"/g, '&quot;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;');
    }

    static escapeHtmlText(value) {
      return String(value == null ? '' : value)
        .replace(/&/g, '&amp;')
        .replace(/</g, '&lt;')
        .replace(/>/g, '&gt;');
    }
  }

  class DownloadQueue {
    constructor(options = {}) {
      this.concurrency = Math.round(Utils.clamp(options.concurrency || 3, 1, 5));
      this.retryCount = Math.round(Utils.clamp(options.retryCount == null ? 3 : options.retryCount, 0, 10));
      this.baseDelayMs = Math.max(0, Number(options.baseDelayMs == null ? 1000 : options.baseDelayMs));
      this.maxDelayMs = Math.max(this.baseDelayMs, Number(options.maxDelayMs || 30000));
      this.jitterRatio = Utils.clamp(options.jitterRatio == null ? 0.2 : options.jitterRatio, 0, 1);
      this.minStartIntervalMs = Math.max(0, Number(options.minStartIntervalMs) || 0);
      this.random = typeof options.random === 'function' ? options.random : Math.random;
      this.onProgress = typeof options.onProgress === 'function' ? options.onProgress : () => {};
      this.externalSignal = options.signal || null;
      this.controller = new AbortController();
      this.state = 'idle';
      this.tasks = [];
      this.results = [];
      this.failures = [];
      this.nextIndex = 0;
      this.completed = 0;
      this.succeeded = 0;
      this.failed = 0;
      this.bytes = 0;
      this.active = new Map();
      this.startedAt = 0;
      this.nextStartAt = 0;
      this.startGate = Promise.resolve();
      this.resumePromise = Promise.resolve();
      this.resumeResolver = null;
      this.externalAbortHandler = () => this.cancel();
    }

    async run(tasks) {
      if (this.state !== 'idle') throw new Error('DownloadQueue 只能运行一次。');
      this.tasks = Array.from(tasks || []);
      this.results = new Array(this.tasks.length);
      this.startedAt = Date.now();
      if (this.externalSignal) {
        if (this.externalSignal.aborted) this.controller.abort();
        else this.externalSignal.addEventListener('abort', this.externalAbortHandler, { once: true });
      }
      if (this.controller.signal.aborted) throw ErrorUtils.abortError();
      this.state = 'running';
      this.notify('state');
      if (this.tasks.length === 0) {
        this.state = 'completed';
        this.notify('state');
        return this.outcome();
      }
      const workerCount = Math.min(this.concurrency, this.tasks.length);
      try {
        await Promise.all(Array.from({ length: workerCount }, () => this.worker()));
        if (this.controller.signal.aborted) throw ErrorUtils.abortError();
        this.state = 'completed';
        this.notify('state');
        return this.outcome();
      } catch (error) {
        if (this.controller.signal.aborted || ErrorUtils.isAbort(error)) {
          this.state = 'cancelled';
          this.notify('state');
          throw ErrorUtils.abortError();
        }
        this.state = 'failed';
        this.notify('state', { error });
        throw error;
      } finally {
        if (this.externalSignal) this.externalSignal.removeEventListener('abort', this.externalAbortHandler);
      }
    }

    async worker() {
      while (true) {
        await this.waitIfPaused();
        Utils.assertNotAborted(this.controller.signal);
        const index = this.nextIndex;
        this.nextIndex += 1;
        if (index >= this.tasks.length) return;
        const task = this.tasks[index];
        this.active.set(index, task.label || task.id || `任务 ${index + 1}`);
        this.notify('task-claimed', { index, task });
        let value;
        let finalError = null;
        for (let attempt = 0; attempt <= this.retryCount; attempt += 1) {
          await this.waitIfPaused();
          await this.reserveStartSlot();
          await this.waitIfPaused();
          Utils.assertNotAborted(this.controller.signal);
          this.notify('task-start', { index, task, attempt });
          try {
            value = await task.run({
              signal: this.controller.signal,
              attempt,
              index,
              task
            });
            finalError = null;
            break;
          } catch (error) {
            if (ErrorUtils.isAbort(error) || this.controller.signal.aborted) throw ErrorUtils.abortError();
            finalError = error;
            const retryable = Boolean(error && error.retryable) || (typeof task.shouldRetry === 'function' && task.shouldRetry(error));
            if (!retryable || attempt >= this.retryCount) break;
            const delay = this.retryDelay(attempt, error);
            this.notify('retry', { index, task, attempt: attempt + 1, delay, error });
            await Utils.sleep(delay, this.controller.signal);
          }
        }

        this.active.delete(index);
        this.completed += 1;
        if (finalError) {
          this.failed += 1;
          const failure = { index, task, error: finalError };
          this.results[index] = { status: 'rejected', reason: finalError, task };
          this.failures.push(failure);
          this.notify('task-failure', failure);
        } else {
          this.succeeded += 1;
          const resultBytes = this.resultBytes(value);
          this.bytes += resultBytes;
          this.results[index] = { status: 'fulfilled', value, task };
          this.notify('task-success', { index, task, value, bytes: resultBytes });
        }
      }
    }

    retryDelay(attempt, error) {
      const exponential = Math.min(this.maxDelayMs, this.baseDelayMs * (2 ** attempt));
      const jitter = exponential * this.jitterRatio * ((this.random() * 2) - 1);
      let delay = Math.max(0, Math.round(exponential + jitter));
      if (error && error.code === 'RATE_LIMIT') delay = Math.max(delay, error.retryAfterMs || 10000);
      return delay;
    }

    async reserveStartSlot() {
      let release;
      const previous = this.startGate;
      this.startGate = new Promise((resolve) => { release = resolve; });
      await previous;
      try {
        const waitMs = Math.max(0, this.nextStartAt - Date.now());
        if (waitMs > 0) await Utils.sleep(waitMs, this.controller.signal);
        this.nextStartAt = Date.now() + this.minStartIntervalMs;
      } finally {
        release();
      }
    }

    waitIfPaused() {
      if (this.state !== 'paused') return Promise.resolve();
      return new Promise((resolve, reject) => {
        const signal = this.controller.signal;
        const onAbort = () => {
          cleanup();
          reject(ErrorUtils.abortError());
        };
        const cleanup = () => signal.removeEventListener('abort', onAbort);
        signal.addEventListener('abort', onAbort, { once: true });
        this.resumePromise.then(() => {
          cleanup();
          if (signal.aborted) reject(ErrorUtils.abortError());
          else resolve();
        });
      });
    }

    pause() {
      if (this.state !== 'running') return false;
      this.state = 'paused';
      this.resumePromise = new Promise((resolve) => { this.resumeResolver = resolve; });
      this.notify('state');
      return true;
    }

    resume() {
      if (this.state !== 'paused') return false;
      this.state = 'running';
      const resolver = this.resumeResolver;
      this.resumeResolver = null;
      if (resolver) resolver();
      this.notify('state');
      return true;
    }

    cancel() {
      if (this.controller.signal.aborted || ['completed', 'cancelled'].includes(this.state)) return false;
      this.controller.abort();
      if (this.resumeResolver) this.resumeResolver();
      return true;
    }

    resultBytes(value) {
      if (!value) return 0;
      if (Number.isFinite(Number(value.bytes))) return Number(value.bytes);
      if (Number.isFinite(Number(value.byteLength))) return Number(value.byteLength);
      if (value.data && Number.isFinite(Number(value.data.byteLength))) return Number(value.data.byteLength);
      return 0;
    }

    snapshot() {
      const elapsedSeconds = Math.max(0.001, (Date.now() - this.startedAt) / 1000);
      return {
        state: this.state,
        total: this.tasks.length,
        completed: this.completed,
        succeeded: this.succeeded,
        failed: this.failed,
        bytes: this.bytes,
        speed: this.bytes / elapsedSeconds,
        percent: this.tasks.length ? Math.round((this.completed / this.tasks.length) * 100) : 100,
        activeLabels: Array.from(this.active.values())
      };
    }

    notify(type, detail = {}) {
      try {
        this.onProgress({ type, ...detail, snapshot: this.snapshot() });
      } catch (_) {
        // Progress rendering must never break the queue.
      }
    }

    outcome() {
      return {
        results: this.results.slice(),
        failures: this.failures.slice(),
        succeeded: this.succeeded,
        failed: this.failed,
        bytes: this.bytes
      };
    }
  }

  class BinaryFetcher {
    constructor(options = {}) {
      this.timeoutProvider = options.timeoutProvider || (() => Config.defaultSettings.requestTimeoutMs);
      this.gmRequest = options.gmRequest || (typeof GM_xmlhttpRequest === 'function' ? GM_xmlhttpRequest : null);
    }

    fetch(url, options = {}) {
      let parsed;
      try {
        parsed = new URL(url);
      } catch (error) {
        return Promise.reject(new PixivDownloaderError('UNSUPPORTED_IMAGE_HOST', '图片 URL 无效。', { cause: error }));
      }
      if (parsed.protocol !== 'https:' || parsed.hostname !== 'i.pximg.net') {
        return Promise.reject(new PixivDownloaderError('UNSUPPORTED_IMAGE_HOST', parsed.hostname || '未知域名'));
      }
      if (!this.gmRequest) {
        return Promise.reject(new PixivDownloaderError('NETWORK', 'GM_xmlhttpRequest 不可用。'));
      }
      const signal = options.signal;
      if (signal && signal.aborted) return Promise.reject(ErrorUtils.abortError());
      const timeout = Number(options.timeoutMs) || Number(this.timeoutProvider()) || Config.defaultSettings.requestTimeoutMs;

      return new Promise((resolve, reject) => {
        let settled = false;
        let requestHandle = null;
        const cleanup = () => {
          if (signal) signal.removeEventListener('abort', onAbort);
        };
        const finishResolve = (value) => {
          if (settled) return;
          settled = true;
          cleanup();
          resolve(value);
        };
        const finishReject = (error) => {
          if (settled) return;
          settled = true;
          cleanup();
          reject(error);
        };
        const onAbort = () => {
          try {
            if (requestHandle && typeof requestHandle.abort === 'function') requestHandle.abort();
          } catch (_) {
            // Ignore abort transport errors; the promise still rejects as cancelled.
          }
          finishReject(ErrorUtils.abortError());
        };

        requestHandle = this.gmRequest({
          method: 'GET',
          url: parsed.toString(),
          headers: {
            Referer: 'https://www.pixiv.net/',
            Accept: 'image/avif,image/webp,image/apng,image/svg+xml,image/*,*/*;q=0.8'
          },
          responseType: 'arraybuffer',
          timeout,
          onprogress: (event) => {
            if (typeof options.onProgress === 'function') {
              options.onProgress({
                loaded: Number(event.loaded) || 0,
                total: Number(event.total) || 0,
                lengthComputable: Boolean(event.lengthComputable)
              });
            }
          },
          onload: (response) => {
            const status = Number(response.status) || 0;
            if (!(status >= 200 && status < 300)) {
              const retryAfter = parseRetryAfter(this.headerValue(response.responseHeaders, 'retry-after'));
              finishReject(mapHttpStatus(status, parsed.pathname, retryAfter));
              return;
            }
            if (!(response.response instanceof ArrayBuffer)) {
              finishReject(new PixivDownloaderError('API_SCHEMA_CHANGED', '图片响应不是 ArrayBuffer。', {
                context: parsed.pathname
              }));
              return;
            }
            finishResolve({
              data: response.response,
              bytes: response.response.byteLength,
              contentType: this.headerValue(response.responseHeaders, 'content-type'),
              finalUrl: response.finalUrl || parsed.toString()
            });
          },
          ontimeout: () => finishReject(new PixivDownloaderError('TIMEOUT', '图片请求超时。', {
            retryable: true,
            context: parsed.pathname
          })),
          onerror: () => finishReject(new PixivDownloaderError('NETWORK', '图片请求失败。', {
            retryable: true,
            context: parsed.pathname
          })),
          onabort: () => {
            if (signal && signal.aborted) finishReject(ErrorUtils.abortError());
            else finishReject(new PixivDownloaderError('NETWORK', '图片请求被中止。', {
              retryable: true,
              context: parsed.pathname
            }));
          }
        });
        if (signal) signal.addEventListener('abort', onAbort, { once: true });
      });
    }

    headerValue(rawHeaders, name) {
      const target = String(name).toLowerCase();
      for (const line of String(rawHeaders || '').split(/\r?\n/)) {
        const index = line.indexOf(':');
        if (index < 0) continue;
        if (line.slice(0, index).trim().toLowerCase() === target) return line.slice(index + 1).trim();
      }
      return '';
    }
  }

  class ZipManager {
    constructor() {
      const ZipClass = typeof JSZip !== 'undefined'
        ? JSZip
        : typeof globalThis !== 'undefined'
          ? globalThis.JSZip
          : null;
      if (typeof ZipClass !== 'function') throw new PixivDownloaderError('ZIP_UNAVAILABLE', 'JSZip 不可用。');
      this.zip = new ZipClass();
    }

    normalizePath(path) {
      return String(path)
        .replace(/\\/g, '/')
        .split('/')
        .filter((part) => part && part !== '.' && part !== '..')
        .join('/');
    }

    addBinary(path, data) {
      this.zip.file(this.normalizePath(path), data, { binary: true, compression: 'STORE' });
    }

    addText(path, text, options = {}) {
      const withBom = options.bom !== false;
      const content = `${withBom ? '\ufeff' : ''}${String(text)}`;
      this.zip.file(this.normalizePath(path), content, { compression: 'DEFLATE' });
    }

    async generate(options = {}) {
      try {
        return await this.zip.generateAsync({
          type: 'blob',
          compression: 'DEFLATE',
          compressionOptions: { level: 6 },
          streamFiles: true
        }, (metadata) => {
          Utils.assertNotAborted(options.signal);
          if (typeof options.onProgress === 'function') options.onProgress(metadata.percent || 0, metadata.currentFile || '');
        });
      } catch (error) {
        if (ErrorUtils.isAbort(error)) throw error;
        if (error instanceof PixivDownloaderError) throw error;
        throw new PixivDownloaderError('ZIP_FAILED', 'ZIP 生成失败。', { cause: error });
      }
    }

    dispose() {
      this.zip = null;
    }
  }

  const ReportUtils = {
    failureLines(failures) {
      return failures.map((failure, index) => {
        const label = failure.label || (failure.task && (failure.task.label || failure.task.id)) || `任务 ${index + 1}`;
        const error = failure.error || failure.reason;
        return `${index + 1}. ${label}\n   ${ErrorUtils.toUserMessage(error)}`;
      });
    },

    failureReport(title, failures) {
      const lines = [title, '', `失败数量:${failures.length}`, '', ...this.failureLines(failures)];
      return Utils.toCrlf(lines.join('\n'));
    },

    queueFailures(outcome) {
      return outcome.failures.map((failure) => ({
        label: failure.task && (failure.task.label || failure.task.id) ? String(failure.task.label || failure.task.id) : `任务 ${failure.index + 1}`,
        error: failure.error
      }));
    }
  };

  const NovelFormatter = {
    plain(novel, resourceMap = new Map()) {
      const metadata = [
        `标题:${novel.title}`,
        `作者:${novel.author}`,
        `作者 ID:${novel.authorId}`,
        `Pixiv Novel ID:${novel.id}`
      ];
      if (novel.seriesTitle) metadata.push(`系列:${novel.seriesTitle}`);
      if (novel.seriesId) metadata.push(`Pixiv Series ID:${novel.seriesId}`);
      if (novel.seriesOrder) metadata.push(`系列顺序:${novel.seriesOrder}`);
      if (novel.tags.length) metadata.push(`标签:${novel.tags.join('、')}`);
      if (novel.createDate) metadata.push(`发布时间:${Utils.formatDate(novel.createDate)}`);
      if (novel.updateDate) metadata.push(`更新时间:${Utils.formatDate(novel.updateDate)}`);
      if (novel.characterCount) metadata.push(`字符数:${novel.characterCount}`);
      if (novel.wordCount) metadata.push(`字数:${novel.wordCount}`);
      if (novel.description) metadata.push('', '简介:', novel.description);
      return [
        ...metadata,
        '',
        '========================================',
        '',
        NovelParser.render(novel.rawText, 'plain', resourceMap)
      ].join('\n').trimEnd();
    },

    markdown(novel, resourceMap = new Map()) {
      const lines = [
        `# ${novel.title}`,
        '',
        `- 作者:${novel.author}`,
        `- 作者 ID:${novel.authorId}`,
        `- Pixiv Novel ID:${novel.id}`
      ];
      if (novel.seriesTitle) lines.push(`- 系列:${novel.seriesTitle}`);
      if (novel.seriesId) lines.push(`- Pixiv Series ID:${novel.seriesId}`);
      if (novel.seriesOrder) lines.push(`- 系列顺序:${novel.seriesOrder}`);
      if (novel.tags.length) lines.push(`- 标签:${novel.tags.map((tag) => `\`${tag.replace(/`/g, '\\`')}\``).join(' ')}`);
      if (novel.createDate) lines.push(`- 发布时间:${Utils.formatDate(novel.createDate)}`);
      if (novel.updateDate) lines.push(`- 更新时间:${Utils.formatDate(novel.updateDate)}`);
      if (novel.characterCount) lines.push(`- 字符数:${novel.characterCount}`);
      if (novel.wordCount) lines.push(`- 字数:${novel.wordCount}`);
      if (novel.description) lines.push('', '## 简介', '', novel.description);
      lines.push('', '## 正文', '', NovelParser.render(novel.rawText, 'markdown', resourceMap));
      return lines.join('\n').trimEnd();
    }
  };

  const SeriesFormatter = {
    info(series, works, chapterFailures = []) {
      const lines = [
        `系列名称:${series.title}`,
        `作者:${series.author}`,
        `作者 ID:${series.authorId}`,
        `Pixiv Series ID:${series.id}`,
        `章节总数:${works.length}`
      ];
      if (series.tags.length) lines.push(`标签:${series.tags.join('、')}`);
      if (series.createDate) lines.push(`创建时间:${Utils.formatDate(series.createDate)}`);
      if (series.updateDate) lines.push(`更新时间:${Utils.formatDate(series.updateDate)}`);
      lines.push(`状态:${series.isConcluded ? '已完结' : '连载中'}`);
      if (series.description) lines.push('', '简介:', series.description);
      lines.push('', '章节目录:');
      const padding = FilenameUtils.getNumberPadding(works.length, 2);
      const failedIds = new Set(chapterFailures.map((failure) => failure.novelId).filter(Boolean));
      for (const work of works) {
        const suffix = failedIds.has(work.novelId) ? ' [下载失败]' : '';
        lines.push(`${String(work.index).padStart(padding, '0')}  ${work.title}  (Novel ID: ${work.novelId})${suffix}`);
      }
      return lines.join('\n');
    },

    merged(series, works, novelsById, chapterFailures = []) {
      const padding = FilenameUtils.getNumberPadding(works.length, 2);
      const failureById = new Map(chapterFailures.map((failure) => [failure.novelId, failure]));
      const lines = [
        `系列名称:${series.title}`,
        `作者:${series.author}`,
        `作者 ID:${series.authorId}`,
        `Pixiv Series ID:${series.id}`,
        `章节总数:${works.length}`
      ];
      if (series.description) lines.push('', '简介:', series.description);
      lines.push('');
      for (const work of works) {
        const number = String(work.index).padStart(padding, '0');
        lines.push('========================================', '', `第${number}章 ${work.title}`, '');
        const novel = novelsById.get(work.novelId);
        if (novel) {
          lines.push(NovelParser.render(novel.rawText, 'plain'));
        } else {
          const failure = failureById.get(work.novelId);
          lines.push(`【本章下载失败:${failure ? ErrorUtils.toUserMessage(failure.error) : '正文不可用'}】`);
        }
        lines.push('');
      }
      return lines.join('\n').trimEnd();
    },

    readme(series, works, chapterFailures = []) {
      const lines = [
        `# ${series.title}`,
        '',
        `- 作者:${series.author}`,
        `- 作者 ID:${series.authorId}`,
        `- Pixiv Series ID:${series.id}`,
        `- 章节总数:${works.length}`
      ];
      if (series.description) lines.push('', '## 简介', '', series.description);
      lines.push('', '## 目录', '');
      const padding = FilenameUtils.getNumberPadding(works.length, 2);
      const failedIds = new Set(chapterFailures.map((failure) => failure.novelId).filter(Boolean));
      for (const work of works) {
        const number = String(work.index).padStart(padding, '0');
        const title = Utils.escapeMarkdownText(work.title);
        const suffix = failedIds.has(work.novelId) ? '(下载失败)' : '';
        lines.push(`- ${number} - ${title}${suffix}`);
      }
      return lines.join('\n');
    }
  };

  class DownloadContext {
    constructor(app, controller) {
      this.app = app;
      this.controller = controller;
      this.signal = controller.signal;
      this.settings = app.settingsManager.get();
      this.queue = null;
      this.warnings = [];
      this.failures = [];
    }

    setTitle(title) {
      this.app.ui.setOperationTitle(title);
    }

    setStatus(status) {
      this.app.ui.setStatus(status);
    }

    warn(message) {
      if (!message || this.warnings.includes(message)) return;
      this.warnings.push(message);
      this.app.ui.setWarnings(this.warnings);
    }

    addFailures(failures) {
      this.failures.push(...failures);
      this.app.ui.setFailures(this.failures);
    }

    setPhaseProgress(percent, label = '') {
      this.app.ui.setPhaseProgress(percent, label);
    }

    createQueue(options = {}) {
      const queue = new DownloadQueue({
        concurrency: options.concurrency || this.settings.imageConcurrency,
        retryCount: options.retryCount == null ? this.settings.retryCount : options.retryCount,
        baseDelayMs: options.baseDelayMs == null ? 1000 : options.baseDelayMs,
        maxDelayMs: options.maxDelayMs || 30000,
        minStartIntervalMs: options.minStartIntervalMs || 0,
        signal: this.signal,
        onProgress: (event) => this.app.ui.updateQueue(event)
      });
      this.queue = queue;
      this.app.attachQueue(queue);
      return queue;
    }

    saveBlob(blob, filename) {
      Utils.assertNotAborted(this.signal);
      Utils.saveBlob(blob, filename);
    }
  }

  class ArtworkDownloader {
    constructor(adapter, binaryFetcher, logger) {
      this.adapter = adapter;
      this.binaryFetcher = binaryFetcher;
      this.logger = logger;
    }

    async download(route, context) {
      context.setStatus('正在获取作品信息...');
      const artwork = await this.adapter.getArtworkInfo(route.id, { signal: context.signal });
      context.setTitle(artwork.title);
      if (artwork.type === 'ugoira') return this.downloadUgoira(artwork, context);

      context.setStatus('正在获取全部 Original 页面...');
      const pages = await this.adapter.getArtworkPages(artwork.id, { signal: context.signal });
      if (pages.length === 0 || (artwork.pageCount > 0 && pages.length !== artwork.pageCount)) {
        throw new PixivDownloaderError('API_SCHEMA_CHANGED', `pages 返回 ${pages.length}/${artwork.pageCount} 页。`, {
          context: 'PixivDataAdapter.getArtworkPages'
        });
      }
      const missing = pages.filter((page) => !page.originalUrl);
      if (missing.length) {
        const reason = artwork.isLoginOnly ? '登录限定作品的 Original URL 为空' : `第 ${missing.map((page) => page.index + 1).join('、')} 页缺少 Original URL`;
        throw new PixivDownloaderError('ORIGINAL_UNAVAILABLE', reason, { context: reason });
      }

      if (pages.length >= 100) {
        context.warn(`此作品包含 ${pages.length} 张原图。JSZip 会在内存中保存全部内容,请确保浏览器有足够内存。`);
      }
      if (pages.length === 1 && artwork.type !== 'manga') return this.downloadSingle(artwork, pages[0], context);
      return this.downloadMultiple(artwork, pages, context);
    }

    metadata(artwork) {
      return {
        author: artwork.author,
        authorId: artwork.authorId,
        title: artwork.title,
        id: artwork.id,
        date: String(artwork.createDate || '').slice(0, 10)
      };
    }

    async downloadSingle(artwork, page, context) {
      context.setStatus('正在下载 Original 原图...');
      const queue = context.createQueue({ concurrency: 1 });
      const outcome = await queue.run([{
        id: `${artwork.id}:0`,
        label: `Original 第 1 页`,
        run: ({ signal }) => this.binaryFetcher.fetch(page.originalUrl, { signal })
      }]);
      if (outcome.failed) throw outcome.failures[0].error;
      const binary = outcome.results[0].value;
      const extension = FilenameUtils.chooseExtension(binary.finalUrl || page.originalUrl, binary.contentType, page.extension || 'bin');
      const base = FilenameUtils.formatFilename(context.settings.imageFilenameTemplate, this.metadata(artwork), {
        requiredToken: 'id',
        requiredValue: artwork.id
      });
      const blob = new Blob([binary.data], { type: binary.contentType || 'application/octet-stream' });
      context.saveBlob(blob, `${base}.${extension}`);
      return { message: `Original 原图下载完成(${Utils.formatBytes(binary.bytes)})。`, failures: [] };
    }

    async downloadMultiple(artwork, pages, context) {
      const zip = new ZipManager();
      const folder = FilenameUtils.sanitizeFilename(artwork.title, { maxLength: 120, fallback: artwork.id });
      const padding = FilenameUtils.getNumberPadding(pages.length, 3);
      context.setStatus(`正在下载 ${pages.length} 张 Original 原图...`);
      const queue = context.createQueue({ concurrency: context.settings.imageConcurrency });
      const tasks = pages.map((page) => {
        const number = String(page.index + 1).padStart(padding, '0');
        return {
          id: `${artwork.id}:${page.index}`,
          label: `${number}.${page.extension || 'img'}`,
          run: async ({ signal }) => {
            const binary = await this.binaryFetcher.fetch(page.originalUrl, { signal });
            const extension = FilenameUtils.chooseExtension(binary.finalUrl || page.originalUrl, binary.contentType, page.extension || 'bin');
            const filename = `${number}.${extension}`;
            zip.addBinary(`${folder}/${filename}`, binary.data);
            return { bytes: binary.bytes, filename };
          }
        };
      });
      const outcome = await queue.run(tasks);
      if (outcome.succeeded === 0) {
        zip.dispose();
        throw outcome.failures[0].error;
      }
      const failures = ReportUtils.queueFailures(outcome);
      if (failures.length) {
        zip.addText(`${folder}/下载失败.txt`, ReportUtils.failureReport('以下 Original 页面下载失败', failures));
        context.addFailures(failures);
      }
      context.setStatus('正在生成 ZIP...');
      const blob = await zip.generate({
        signal: context.signal,
        onProgress: (percent, currentFile) => context.setPhaseProgress(percent, currentFile ? `压缩:${currentFile}` : '正在压缩')
      });
      const base = FilenameUtils.formatFilename(context.settings.imageFilenameTemplate, this.metadata(artwork), {
        requiredToken: 'id',
        requiredValue: artwork.id
      });
      context.saveBlob(blob, `${base}.zip`);
      zip.dispose();
      return {
        message: `ZIP 下载完成:成功 ${outcome.succeeded},失败 ${outcome.failed}。`,
        failures
      };
    }

    async downloadUgoira(artwork, context) {
      context.warn('Ugoira 将保存 Pixiv 原始帧 ZIP 与帧延时元数据,不转换为 GIF 或视频。');
      context.setStatus('正在获取 Ugoira 帧元数据...');
      const metadata = await this.adapter.getUgoiraMeta(artwork.id, { signal: context.signal });
      const queue = context.createQueue({ concurrency: 1 });
      context.setStatus('正在下载 Ugoira 原始帧 ZIP...');
      const outcome = await queue.run([{
        id: `${artwork.id}:ugoira`,
        label: 'Ugoira 原始帧 ZIP',
        run: ({ signal }) => this.binaryFetcher.fetch(metadata.originalSrc, { signal })
      }]);
      if (outcome.failed) throw outcome.failures[0].error;
      const binary = outcome.results[0].value;
      const zip = new ZipManager();
      const folder = FilenameUtils.sanitizeFilename(artwork.title, { maxLength: 120, fallback: artwork.id });
      zip.addBinary(`${folder}/frames-original.zip`, binary.data);
      zip.addText(`${folder}/animation.json`, JSON.stringify({
        pixivIllustId: artwork.id,
        title: artwork.title,
        frameCount: metadata.frames.length,
        frames: metadata.frames,
        format: 'Pixiv Ugoira original frame archive',
        verifiedAdapterDate: VERIFIED_DATE
      }, null, 2), { bom: false });
      zip.addText(`${folder}/README.txt`, Utils.toCrlf([
        '此文件保存 Pixiv 提供的 Ugoira 原始帧 ZIP。',
        'animation.json 中记录每一帧的文件名和延时(毫秒)。',
        '当前版本不进行 GIF 或视频转换。'
      ].join('\n')));
      context.setStatus('正在生成 Ugoira 外层 ZIP...');
      const blob = await zip.generate({
        signal: context.signal,
        onProgress: (percent, currentFile) => context.setPhaseProgress(percent, currentFile || '正在压缩')
      });
      const base = FilenameUtils.formatFilename(context.settings.imageFilenameTemplate, this.metadata(artwork), {
        requiredToken: 'id',
        requiredValue: artwork.id
      });
      context.saveBlob(blob, `${base} - Ugoira.zip`);
      zip.dispose();
      return { message: `Ugoira 原始帧包下载完成,共 ${metadata.frames.length} 帧。`, failures: [] };
    }
  }

  class NovelDownloader {
    constructor(adapter, binaryFetcher) {
      this.adapter = adapter;
      this.binaryFetcher = binaryFetcher;
    }

    async getNovel(route, signal) {
      if (route.type === 'novel-series-content') {
        return this.adapter.getNovelBySeriesContent(route.seriesId, route.contentOrder, { signal });
      }
      return this.adapter.getNovelInfo(route.id, { signal });
    }

    metadata(novel) {
      return {
        author: novel.author,
        authorId: novel.authorId,
        title: novel.title,
        id: novel.id,
        series: novel.seriesTitle,
        seriesId: novel.seriesId,
        date: String(novel.createDate || '').slice(0, 10)
      };
    }

    async download(route, context) {
      context.setStatus('正在获取完整小说正文...');
      const novel = await this.getNovel(route, context.signal);
      context.setTitle(novel.title);
      const base = FilenameUtils.formatFilename(context.settings.novelFilenameTemplate, this.metadata(novel), {
        requiredToken: 'id',
        requiredValue: novel.id
      });
      if (!context.settings.downloadEmbeddedImages) return this.downloadTextOnly(novel, base, context);
      return this.downloadWithImages(novel, base, context);
    }

    async downloadTextOnly(novel, base, context) {
      const format = context.settings.novelFormat;
      const plain = format !== 'markdown' ? NovelFormatter.plain(novel) : '';
      const markdown = format !== 'txt' ? NovelFormatter.markdown(novel) : '';
      if (format === 'txt') {
        const blob = new Blob(['\ufeff', Utils.toCrlf(plain)], { type: 'text/plain;charset=utf-8' });
        context.saveBlob(blob, `${base}.txt`);
        return { message: '小说 TXT 下载完成。', failures: [] };
      }
      if (format === 'markdown') {
        const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
        context.saveBlob(blob, `${base}.md`);
        return { message: '小说 Markdown 下载完成。', failures: [] };
      }
      const zip = new ZipManager();
      const folder = FilenameUtils.sanitizeFilename(novel.title, { maxLength: 120, fallback: novel.id });
      zip.addText(`${folder}/${FilenameUtils.sanitizeFilename(novel.title, { maxLength: 110 })}.txt`, Utils.toCrlf(plain));
      zip.addText(`${folder}/${FilenameUtils.sanitizeFilename(novel.title, { maxLength: 110 })}.md`, markdown, { bom: false });
      context.setStatus('正在生成 TXT + Markdown ZIP...');
      const blob = await zip.generate({
        signal: context.signal,
        onProgress: (percent, currentFile) => context.setPhaseProgress(percent, currentFile || '正在压缩')
      });
      context.saveBlob(blob, `${base}.zip`);
      zip.dispose();
      return { message: '小说 TXT 与 Markdown 下载完成。', failures: [] };
    }

    async downloadWithImages(novel, base, context) {
      context.setStatus('正在解析小说内嵌图片...');
      const descriptors = await this.adapter.resolveNovelEmbeddedImages(novel, { signal: context.signal });
      const zip = new ZipManager();
      const folder = FilenameUtils.sanitizeFilename(novel.title, { maxLength: 120, fallback: novel.id });
      const imageMap = new Map();
      const unavailable = [];
      const downloadable = descriptors.filter((descriptor) => {
        if (descriptor.available && descriptor.originalUrl) return true;
        unavailable.push({
          label: descriptor.kind === 'pixiv' ? `Pixiv 插图 ${descriptor.citationId}` : `上传图片 ${descriptor.imageId}`,
          error: new PixivDownloaderError('CONTENT_UNAVAILABLE', descriptor.unavailableReason || '图片不可用')
        });
        return false;
      });
      const padding = FilenameUtils.getNumberPadding(descriptors.length, 3);
      context.setStatus(`正在下载 ${downloadable.length} 张小说内嵌图片...`);
      const queue = context.createQueue({ concurrency: context.settings.imageConcurrency });
      const tasks = downloadable.map((descriptor) => {
        const sourceIndex = descriptors.findIndex((item) => item.key === descriptor.key);
        const number = String(sourceIndex + 1).padStart(padding, '0');
        return {
          id: descriptor.key,
          label: `images/${number}`,
          run: async ({ signal }) => {
            const binary = await this.binaryFetcher.fetch(descriptor.originalUrl, { signal });
            const extension = FilenameUtils.chooseExtension(binary.finalUrl || descriptor.originalUrl, binary.contentType, 'bin');
            const localPath = `images/${number}.${extension}`;
            zip.addBinary(`${folder}/${localPath}`, binary.data);
            imageMap.set(descriptor.key, { localPath });
            return { bytes: binary.bytes, localPath };
          }
        };
      });
      const outcome = await queue.run(tasks);
      const failures = [...unavailable, ...ReportUtils.queueFailures(outcome)];
      if (failures.length) {
        zip.addText(`${folder}/图片下载失败.txt`, ReportUtils.failureReport('以下小说内嵌图片未能保存', failures));
        context.addFailures(failures);
      }
      const plain = NovelFormatter.plain(novel, imageMap);
      const markdown = NovelFormatter.markdown(novel, imageMap);
      const safeTitle = FilenameUtils.sanitizeFilename(novel.title, { maxLength: 110, fallback: novel.id });
      zip.addText(`${folder}/${safeTitle}.md`, markdown, { bom: false });
      if (context.settings.novelFormat !== 'markdown') {
        zip.addText(`${folder}/${safeTitle}.txt`, Utils.toCrlf(plain));
      }
      context.setStatus('正在生成小说 ZIP...');
      const blob = await zip.generate({
        signal: context.signal,
        onProgress: (percent, currentFile) => context.setPhaseProgress(percent, currentFile || '正在压缩')
      });
      context.saveBlob(blob, `${base}.zip`);
      zip.dispose();
      return {
        message: `小说 ZIP 下载完成:图片成功 ${outcome.succeeded},失败 ${failures.length}。`,
        failures
      };
    }
  }

  class NovelSeriesDownloader {
    constructor(adapter, binaryFetcher) {
      this.adapter = adapter;
      this.binaryFetcher = binaryFetcher;
    }

    metadata(series) {
      return {
        author: series.author,
        authorId: series.authorId,
        title: series.title,
        series: series.title,
        seriesId: series.id,
        id: series.id,
        date: String(series.createDate || '').slice(0, 10)
      };
    }

    async download(route, context) {
      context.setStatus('正在获取系列信息...');
      const series = await this.adapter.getNovelSeriesInfo(route.seriesId, { signal: context.signal });
      context.setTitle(series.title);
      context.setStatus('正在获取完整系列目录...');
      const works = await this.adapter.getNovelSeriesWorks(series.id, {
        signal: context.signal,
        expectedTotal: series.total,
        onPage: (progress) => {
          if (progress.source === 'series_content') context.setStatus(`正在获取系列目录:已找到 ${progress.count} 章...`);
        }
      });
      if (works.length >= 100) {
        context.warn(`该系列包含 ${works.length} 章。请求会限速执行,生成 ZIP 时需要足够浏览器内存。`);
      }

      const padding = FilenameUtils.getNumberPadding(works.length, 2);
      context.setStatus(`正在下载 ${works.length} 章完整正文...`);
      const chapterQueue = context.createQueue({
        concurrency: Config.seriesRequestConcurrency,
        minStartIntervalMs: Config.seriesRequestIntervalMs
      });
      const chapterOutcome = await chapterQueue.run(works.map((work) => ({
        id: work.novelId,
        label: `${String(work.index).padStart(padding, '0')} - ${work.title}`,
        run: async ({ signal }) => {
          const novel = await this.adapter.getNovelInfo(work.novelId, { signal, retries: 0 });
          return { novel, bytes: Utils.textByteLength(novel.rawText) };
        }
      })));
      if (chapterOutcome.succeeded === 0) throw chapterOutcome.failures[0].error;

      const novelsById = new Map();
      for (const result of chapterOutcome.results) {
        if (result && result.status === 'fulfilled') novelsById.set(result.value.novel.id, result.value.novel);
      }
      const chapterFailures = chapterOutcome.failures.map((failure) => ({
        label: failure.task.label,
        error: failure.error,
        novelId: failure.task.id,
        index: failure.index + 1
      }));
      if (chapterFailures.length) context.addFailures(chapterFailures);

      const base = FilenameUtils.formatFilename(context.settings.seriesFilenameTemplate, this.metadata(series), {
        requiredToken: 'seriesId',
        requiredValue: series.id
      });
      if (context.settings.seriesFormat === 'merged-txt' && !context.settings.downloadEmbeddedImages) {
        const merged = SeriesFormatter.merged(series, works, novelsById, chapterFailures);
        const blob = new Blob(['\ufeff', Utils.toCrlf(merged)], { type: 'text/plain;charset=utf-8' });
        context.saveBlob(blob, `${base}.txt`);
        return {
          message: `系列合并 TXT 下载完成:成功 ${chapterOutcome.succeeded},失败 ${chapterOutcome.failed}。`,
          failures: chapterFailures
        };
      }
      return this.buildSeriesZip(series, works, novelsById, chapterFailures, base, context);
    }

    async buildSeriesZip(series, works, novelsById, chapterFailures, base, context) {
      const zip = new ZipManager();
      const folder = FilenameUtils.sanitizeFilename(series.title, { maxLength: 120, fallback: series.id });
      const padding = FilenameUtils.getNumberPadding(works.length, 2);
      zip.addText(`${folder}/000 - 系列信息.txt`, Utils.toCrlf(SeriesFormatter.info(series, works, chapterFailures)));

      for (const work of works) {
        const number = String(work.index).padStart(padding, '0');
        const safeTitle = FilenameUtils.sanitizeFilename(work.title, { maxLength: 100, fallback: work.novelId });
        const novel = novelsById.get(work.novelId);
        const body = novel
          ? NovelFormatter.plain(novel)
          : `标题:${work.title}\r\nPixiv Novel ID:${work.novelId}\r\n\r\n【本章下载失败,请在 Pixiv 中检查访问权限后重试。】`;
        zip.addText(`${folder}/${number} - ${safeTitle}.txt`, Utils.toCrlf(body));
      }

      if (context.settings.seriesFormat === 'both' || context.settings.seriesFormat === 'merged-txt') {
        const merged = SeriesFormatter.merged(series, works, novelsById, chapterFailures);
        zip.addText(`${folder}/系列全文.txt`, Utils.toCrlf(merged));
      }

      const imageFailures = [];
      if (context.settings.downloadEmbeddedImages) {
        await this.addSeriesMarkdownAndImages(zip, folder, series, works, novelsById, chapterFailures, imageFailures, context);
      }

      const allFailures = [...chapterFailures, ...imageFailures];
      if (allFailures.length) {
        zip.addText(`${folder}/下载失败汇总.txt`, ReportUtils.failureReport('系列下载中的失败任务', allFailures));
        if (imageFailures.length) context.addFailures(imageFailures);
      }
      context.setStatus('正在生成系列 ZIP...');
      const blob = await zip.generate({
        signal: context.signal,
        onProgress: (percent, currentFile) => context.setPhaseProgress(percent, currentFile || '正在压缩')
      });
      context.saveBlob(blob, `${base}.zip`);
      zip.dispose();
      return {
        message: `系列 ZIP 下载完成:章节成功 ${novelsById.size},章节失败 ${chapterFailures.length},图片失败 ${imageFailures.length}。`,
        failures: allFailures
      };
    }

    async addSeriesMarkdownAndImages(zip, folder, series, works, novelsById, chapterFailures, imageFailures, context) {
      const successfulWorks = works.filter((work) => novelsById.has(work.novelId));
      const padding = FilenameUtils.getNumberPadding(works.length, 2);
      context.setStatus('正在解析各章内嵌图片...');
      const resolveQueue = context.createQueue({
        concurrency: Config.seriesRequestConcurrency,
        minStartIntervalMs: Config.seriesRequestIntervalMs
      });
      const resolveOutcome = await resolveQueue.run(successfulWorks.map((work) => ({
        id: work.novelId,
        label: `${String(work.index).padStart(padding, '0')} - 解析图片`,
        run: async ({ signal }) => {
          const novel = novelsById.get(work.novelId);
          const descriptors = await this.adapter.resolveNovelEmbeddedImages(novel, { signal });
          return { work, descriptors };
        }
      })));

      const descriptorsByNovel = new Map();
      for (const result of resolveOutcome.results) {
        if (result && result.status === 'fulfilled') {
          descriptorsByNovel.set(result.value.work.novelId, result.value.descriptors);
        }
      }
      for (const failure of ReportUtils.queueFailures(resolveOutcome)) imageFailures.push(failure);

      const resourceMaps = new Map();
      const imageTasks = [];
      for (const work of successfulWorks) {
        const descriptors = descriptorsByNovel.get(work.novelId) || [];
        const resourceMap = new Map();
        resourceMaps.set(work.novelId, resourceMap);
        const imagePadding = FilenameUtils.getNumberPadding(descriptors.length, 3);
        descriptors.forEach((descriptor, index) => {
          const number = String(index + 1).padStart(imagePadding, '0');
          if (!descriptor.available || !descriptor.originalUrl) {
            imageFailures.push({
              label: `第 ${work.index} 章图片 ${number}`,
              error: new PixivDownloaderError('CONTENT_UNAVAILABLE', descriptor.unavailableReason || '图片不可用')
            });
            return;
          }
          imageTasks.push({
            id: `${work.novelId}:${descriptor.key}`,
            label: `第 ${work.index} 章图片 ${number}`,
            run: async ({ signal }) => {
              const binary = await this.binaryFetcher.fetch(descriptor.originalUrl, { signal });
              const extension = FilenameUtils.chooseExtension(binary.finalUrl || descriptor.originalUrl, binary.contentType, 'bin');
              const relativePath = `images/chapter${String(work.index).padStart(padding, '0')}/${number}.${extension}`;
              zip.addBinary(`${folder}/${relativePath}`, binary.data);
              resourceMap.set(descriptor.key, { localPath: relativePath });
              return { bytes: binary.bytes, localPath: relativePath };
            }
          });
        });
      }

      context.setStatus(`正在下载系列内嵌图片(${imageTasks.length} 张)...`);
      const imageQueue = context.createQueue({ concurrency: context.settings.imageConcurrency });
      const imageOutcome = await imageQueue.run(imageTasks);
      imageFailures.push(...ReportUtils.queueFailures(imageOutcome));

      zip.addText(`${folder}/README.md`, SeriesFormatter.readme(series, works, chapterFailures), { bom: false });
      for (const work of works) {
        const number = String(work.index).padStart(padding, '0');
        const safeTitle = FilenameUtils.sanitizeFilename(work.title, { maxLength: 100, fallback: work.novelId });
        const novel = novelsById.get(work.novelId);
        const markdown = novel
          ? NovelFormatter.markdown(novel, resourceMaps.get(work.novelId) || new Map())
          : `# ${work.title}\n\n本章下载失败,请在 Pixiv 中检查访问权限后重试。`;
        zip.addText(`${folder}/${number} - ${safeTitle}.md`, markdown, { bom: false });
      }
    }
  }

  class UIManager {
    constructor(settingsManager) {
      this.settingsManager = settingsManager;
      this.settings = settingsManager.get();
      this.handlers = {};
      this.route = { type: 'other', key: 'other' };
      this.busy = false;
      this.startedAt = 0;
      this.elements = {};
      this.mounted = false;
    }

    mount(handlers = {}) {
      if (this.mounted || typeof document === 'undefined') return;
      this.handlers = handlers;
      this.injectStyles();
      const root = document.createElement('div');
      root.id = 'pdl-root';
      root.innerHTML = `
        <button id="pdl-action" type="button" aria-label="下载当前 Pixiv 作品">
          <span class="pdl-action-icon" aria-hidden="true">↓</span>
          <span id="pdl-action-label">下载当前作品</span>
        </button>
        <section id="pdl-panel" aria-live="polite" hidden>
          <header class="pdl-panel-header">
            <div class="pdl-panel-heading">
              <strong>Pixiv Downloader</strong>
              <span id="pdl-operation-title"></span>
            </div>
            <div class="pdl-header-actions">
              <button id="pdl-settings-button" class="pdl-icon-button" type="button" title="设置" aria-label="设置">⚙</button>
              <button id="pdl-hide-button" class="pdl-icon-button" type="button" title="隐藏" aria-label="隐藏">−</button>
            </div>
          </header>
          <div class="pdl-panel-body">
            <div id="pdl-status">准备下载</div>
            <div class="pdl-progress-track" role="progressbar" aria-valuemin="0" aria-valuemax="100" aria-valuenow="0">
              <div id="pdl-progress-bar"></div>
            </div>
            <div class="pdl-stats">
              <span id="pdl-count">0 / 0</span>
              <span id="pdl-percent">0%</span>
              <span id="pdl-bytes">0 B</span>
              <span id="pdl-speed">0 B/s</span>
            </div>
            <div id="pdl-current" class="pdl-current"></div>
            <ul id="pdl-warnings" class="pdl-message-list pdl-warning-list" hidden></ul>
            <ul id="pdl-failures" class="pdl-message-list pdl-failure-list" hidden></ul>
          </div>
          <footer class="pdl-panel-footer">
            <button id="pdl-pause-button" type="button" disabled>暂停</button>
            <button id="pdl-cancel-button" class="pdl-danger-button" type="button" disabled>取消</button>
          </footer>
        </section>
        <div id="pdl-modal-backdrop" hidden>
          <form id="pdl-settings-form" aria-label="Pixiv Downloader 设置">
            <header class="pdl-settings-header">
              <strong>Pixiv Downloader 设置</strong>
              <button id="pdl-settings-close" class="pdl-icon-button" type="button" title="关闭" aria-label="关闭">×</button>
            </header>
            <div class="pdl-settings-body">
              <label>图片质量
                <select disabled><option>Original(固定)</option></select>
              </label>
              <div class="pdl-field-row">
                <label>图片并发
                  <input name="imageConcurrency" type="number" min="1" max="5" step="1">
                </label>
                <label>失败重试
                  <input name="retryCount" type="number" min="0" max="5" step="1">
                </label>
              </div>
              <label>请求超时(秒)
                <input name="requestTimeoutSeconds" type="number" min="10" max="120" step="5">
              </label>
              <div class="pdl-field-row">
                <label>单篇小说格式
                  <select name="novelFormat">
                    <option value="txt">TXT</option>
                    <option value="markdown">Markdown</option>
                    <option value="both">TXT + Markdown ZIP</option>
                  </select>
                </label>
                <label>系列格式
                  <select name="seriesFormat">
                    <option value="merged-txt">合并 TXT</option>
                    <option value="chapter-zip">分章 ZIP</option>
                    <option value="both">分章 ZIP + 合并 TXT</option>
                  </select>
                </label>
              </div>
              <label class="pdl-check"><input name="downloadEmbeddedImages" type="checkbox">下载小说内嵌图片</label>
              <label class="pdl-check"><input name="notifications" type="checkbox">完成后发送通知</label>
              <label class="pdl-check"><input name="showFloatingButton" type="checkbox">显示右下角下载按钮</label>
              <label class="pdl-check"><input name="debug" type="checkbox">启用 DEBUG 日志</label>
              <label>图片文件名模板
                <input name="imageFilenameTemplate" type="text" maxlength="240">
              </label>
              <label>小说文件名模板
                <input name="novelFilenameTemplate" type="text" maxlength="240">
              </label>
              <label>系列文件名模板
                <input name="seriesFilenameTemplate" type="text" maxlength="240">
              </label>
            </div>
            <footer class="pdl-settings-footer">
              <button id="pdl-settings-reset" type="button">恢复默认</button>
              <div>
                <button id="pdl-settings-cancel" type="button">取消</button>
                <button class="pdl-primary-button" type="submit">保存</button>
              </div>
            </footer>
          </form>
        </div>`;
      document.body.appendChild(root);
      this.elements = {
        root,
        action: root.querySelector('#pdl-action'),
        actionLabel: root.querySelector('#pdl-action-label'),
        panel: root.querySelector('#pdl-panel'),
        title: root.querySelector('#pdl-operation-title'),
        status: root.querySelector('#pdl-status'),
        progressTrack: root.querySelector('.pdl-progress-track'),
        progressBar: root.querySelector('#pdl-progress-bar'),
        count: root.querySelector('#pdl-count'),
        percent: root.querySelector('#pdl-percent'),
        bytes: root.querySelector('#pdl-bytes'),
        speed: root.querySelector('#pdl-speed'),
        current: root.querySelector('#pdl-current'),
        warnings: root.querySelector('#pdl-warnings'),
        failures: root.querySelector('#pdl-failures'),
        pause: root.querySelector('#pdl-pause-button'),
        cancel: root.querySelector('#pdl-cancel-button'),
        hide: root.querySelector('#pdl-hide-button'),
        settingsButton: root.querySelector('#pdl-settings-button'),
        modal: root.querySelector('#pdl-modal-backdrop'),
        settingsForm: root.querySelector('#pdl-settings-form'),
        settingsClose: root.querySelector('#pdl-settings-close'),
        settingsCancel: root.querySelector('#pdl-settings-cancel'),
        settingsReset: root.querySelector('#pdl-settings-reset')
      };
      this.bindEvents();
      this.syncSettingsForm(this.settings);
      this.mounted = true;
      this.applyActionVisibility();
    }

    injectStyles() {
      const css = `
        #pdl-root, #pdl-root * { box-sizing: border-box; letter-spacing: 0; }
        #pdl-root { --pdl-bg: #ffffff; --pdl-surface: #f4f5f7; --pdl-border: #d8dce2; --pdl-text: #20242a; --pdl-muted: #66717e; --pdl-blue: #0096fa; --pdl-blue-hover: #007fd6; --pdl-red: #d9363e; --pdl-red-hover: #b82b32; --pdl-amber: #9a6200; --pdl-amber-bg: #fff4d6; font: 13px/1.45 Arial, "Microsoft YaHei", sans-serif; color: var(--pdl-text); }
        #pdl-action { position: fixed; right: 24px; bottom: 24px; z-index: 2147483644; display: inline-flex; align-items: center; gap: 8px; min-height: 42px; max-width: calc(100vw - 32px); padding: 0 16px; border: 0; border-radius: 8px; color: #fff; background: var(--pdl-blue); box-shadow: 0 4px 16px rgba(0,0,0,.22); font: 600 14px/1 Arial, "Microsoft YaHei", sans-serif; cursor: pointer; }
        #pdl-action:hover { background: var(--pdl-blue-hover); }
        #pdl-action:disabled { cursor: wait; opacity: .65; }
        .pdl-action-icon { font-size: 20px; line-height: 1; }
        #pdl-panel { position: fixed; right: 24px; bottom: 78px; z-index: 2147483644; width: 370px; max-width: calc(100vw - 32px); overflow: hidden; border: 1px solid var(--pdl-border); border-radius: 8px; background: var(--pdl-bg); box-shadow: 0 10px 34px rgba(0,0,0,.24); }
        #pdl-panel[hidden], #pdl-modal-backdrop[hidden], #pdl-action[hidden] { display: none !important; }
        .pdl-panel-header, .pdl-settings-header { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 48px; padding: 8px 12px; border-bottom: 1px solid var(--pdl-border); }
        .pdl-panel-heading { display: grid; min-width: 0; }
        .pdl-panel-heading strong { font-size: 14px; }
        #pdl-operation-title { overflow: hidden; color: var(--pdl-muted); font-size: 12px; text-overflow: ellipsis; white-space: nowrap; }
        .pdl-header-actions { display: flex; gap: 4px; flex: 0 0 auto; }
        .pdl-icon-button { display: inline-grid; place-items: center; width: 30px; height: 30px; padding: 0; border: 1px solid transparent; border-radius: 6px; color: var(--pdl-text); background: transparent; font-size: 17px; cursor: pointer; }
        .pdl-icon-button:hover { border-color: var(--pdl-border); background: var(--pdl-surface); }
        .pdl-panel-body { padding: 12px; }
        #pdl-status { min-height: 20px; font-weight: 600; overflow-wrap: anywhere; }
        .pdl-progress-track { height: 8px; margin-top: 10px; overflow: hidden; border-radius: 4px; background: #dfe3e8; }
        #pdl-progress-bar { width: 0; height: 100%; background: var(--pdl-blue); transition: width .15s ease; }
        .pdl-stats { display: grid; grid-template-columns: 1fr 52px 74px 84px; gap: 6px; margin-top: 8px; color: var(--pdl-muted); font-variant-numeric: tabular-nums; }
        .pdl-stats span { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
        .pdl-stats span:not(:first-child) { text-align: right; }
        .pdl-current { min-height: 18px; margin-top: 8px; overflow: hidden; color: var(--pdl-muted); text-overflow: ellipsis; white-space: nowrap; }
        .pdl-message-list { max-height: 104px; margin: 10px 0 0; padding: 8px 8px 8px 26px; overflow: auto; border-radius: 6px; overflow-wrap: anywhere; }
        .pdl-warning-list { color: var(--pdl-amber); background: var(--pdl-amber-bg); }
        .pdl-failure-list { color: #a9232a; background: #ffe9ea; }
        .pdl-panel-footer, .pdl-settings-footer { display: flex; align-items: center; justify-content: flex-end; gap: 8px; min-height: 48px; padding: 8px 12px; border-top: 1px solid var(--pdl-border); background: var(--pdl-surface); }
        #pdl-root button:not(.pdl-icon-button):not(#pdl-action) { min-height: 32px; padding: 0 13px; border: 1px solid var(--pdl-border); border-radius: 6px; color: var(--pdl-text); background: var(--pdl-bg); font: 600 13px/1 Arial, "Microsoft YaHei", sans-serif; cursor: pointer; }
        #pdl-root button:not(.pdl-icon-button):not(#pdl-action):hover { background: var(--pdl-surface); }
        #pdl-root button:disabled { cursor: default; opacity: .45; }
        #pdl-root .pdl-danger-button { color: #fff !important; border-color: var(--pdl-red) !important; background: var(--pdl-red) !important; }
        #pdl-root .pdl-danger-button:hover { background: var(--pdl-red-hover) !important; }
        #pdl-root .pdl-primary-button { color: #fff !important; border-color: var(--pdl-blue) !important; background: var(--pdl-blue) !important; }
        #pdl-modal-backdrop { position: fixed; inset: 0; z-index: 2147483646; display: grid; place-items: center; padding: 16px; background: rgba(0,0,0,.48); }
        #pdl-settings-form { width: 560px; max-width: calc(100vw - 32px); min-width: 0; max-height: calc(100vh - 32px); overflow: hidden; border: 1px solid var(--pdl-border); border-radius: 8px; background: var(--pdl-bg); box-shadow: 0 16px 50px rgba(0,0,0,.32); }
        .pdl-settings-body { display: grid; gap: 12px; max-height: calc(100vh - 150px); padding: 14px; overflow: auto; }
        .pdl-settings-body label { display: grid; gap: 5px; min-width: 0; color: var(--pdl-muted); font-weight: 600; }
        .pdl-field-row { display: grid; grid-template-columns: 1fr 1fr; gap: 12px; min-width: 0; }
        .pdl-settings-body input[type="text"], .pdl-settings-body input[type="number"], .pdl-settings-body select { width: 100%; min-width: 0; height: 34px; padding: 0 9px; border: 1px solid var(--pdl-border); border-radius: 6px; color: var(--pdl-text); background: var(--pdl-bg); font: 13px/1 Arial, "Microsoft YaHei", sans-serif; }
        .pdl-settings-body select:disabled { color: var(--pdl-muted); background: var(--pdl-surface); }
        .pdl-settings-body .pdl-check { display: flex; align-items: center; gap: 8px; color: var(--pdl-text); }
        .pdl-check input { width: 16px; height: 16px; margin: 0; accent-color: var(--pdl-blue); }
        .pdl-settings-footer { justify-content: space-between; }
        .pdl-settings-footer > div { display: flex; gap: 8px; }
        @media (prefers-color-scheme: dark) {
          #pdl-root { --pdl-bg: #24272c; --pdl-surface: #1b1e22; --pdl-border: #424850; --pdl-text: #f2f4f7; --pdl-muted: #aeb7c2; --pdl-blue: #00a4f5; --pdl-blue-hover: #24b4fa; --pdl-amber: #ffd269; --pdl-amber-bg: #493810; }
          .pdl-progress-track { background: #4a5058; }
          .pdl-failure-list { color: #ffb6ba; background: #54262a; }
        }
        @media (max-width: 520px) {
          #pdl-action { right: 16px; bottom: 16px; }
          #pdl-panel { right: 16px; bottom: 68px; }
          .pdl-field-row { grid-template-columns: 1fr; }
          .pdl-stats { grid-template-columns: 1fr 44px 68px; }
          #pdl-speed { display: none; }
        }
      `;
      if (typeof GM_addStyle === 'function') GM_addStyle(css);
      else {
        const style = document.createElement('style');
        style.textContent = css;
        document.head.appendChild(style);
      }
    }

    bindEvents() {
      this.elements.action.addEventListener('click', () => {
        if (typeof this.handlers.onAction === 'function') this.handlers.onAction();
      });
      this.elements.pause.addEventListener('click', () => {
        if (typeof this.handlers.onPauseToggle === 'function') this.handlers.onPauseToggle();
      });
      this.elements.cancel.addEventListener('click', () => {
        if (typeof this.handlers.onCancel === 'function') this.handlers.onCancel();
      });
      this.elements.hide.addEventListener('click', () => { this.elements.panel.hidden = true; });
      this.elements.settingsButton.addEventListener('click', () => this.showSettings());
      this.elements.settingsClose.addEventListener('click', () => this.hideSettings());
      this.elements.settingsCancel.addEventListener('click', () => this.hideSettings());
      this.elements.modal.addEventListener('click', (event) => {
        if (event.target === this.elements.modal) this.hideSettings();
      });
      this.elements.settingsReset.addEventListener('click', () => this.syncSettingsForm({ ...Config.defaultSettings }));
      this.elements.settingsForm.addEventListener('submit', (event) => {
        event.preventDefault();
        const form = new FormData(this.elements.settingsForm);
        const next = {
          imageConcurrency: Number(form.get('imageConcurrency')),
          retryCount: Number(form.get('retryCount')),
          requestTimeoutMs: Number(form.get('requestTimeoutSeconds')) * 1000,
          novelFormat: String(form.get('novelFormat')),
          seriesFormat: String(form.get('seriesFormat')),
          downloadEmbeddedImages: form.has('downloadEmbeddedImages'),
          notifications: form.has('notifications'),
          showFloatingButton: form.has('showFloatingButton'),
          debug: form.has('debug'),
          imageFilenameTemplate: String(form.get('imageFilenameTemplate') || ''),
          novelFilenameTemplate: String(form.get('novelFilenameTemplate') || ''),
          seriesFilenameTemplate: String(form.get('seriesFilenameTemplate') || '')
        };
        if (typeof this.handlers.onSaveSettings === 'function') this.handlers.onSaveSettings(next);
        this.hideSettings();
      });
    }

    updateSettings(settings) {
      this.settings = { ...settings };
      this.syncSettingsForm(this.settings);
      this.applyActionVisibility();
    }

    syncSettingsForm(settings) {
      if (!this.elements.settingsForm) return;
      const form = this.elements.settingsForm.elements;
      form.imageConcurrency.value = settings.imageConcurrency;
      form.retryCount.value = settings.retryCount;
      form.requestTimeoutSeconds.value = Math.round(settings.requestTimeoutMs / 1000);
      form.novelFormat.value = settings.novelFormat;
      form.seriesFormat.value = settings.seriesFormat;
      form.downloadEmbeddedImages.checked = settings.downloadEmbeddedImages;
      form.notifications.checked = settings.notifications;
      form.showFloatingButton.checked = settings.showFloatingButton;
      form.debug.checked = settings.debug;
      form.imageFilenameTemplate.value = settings.imageFilenameTemplate;
      form.novelFilenameTemplate.value = settings.novelFilenameTemplate;
      form.seriesFilenameTemplate.value = settings.seriesFilenameTemplate;
    }

    showSettings() {
      this.syncSettingsForm(this.settings);
      this.elements.modal.hidden = false;
    }

    hideSettings() {
      this.elements.modal.hidden = true;
    }

    setRoute(route, label) {
      this.route = route;
      this.elements.actionLabel.textContent = label || '下载当前作品';
      this.applyActionVisibility();
    }

    applyActionVisibility() {
      if (!this.mounted) return;
      this.elements.action.hidden = !this.settings.showFloatingButton || this.route.type === 'other';
      this.elements.action.disabled = this.busy;
    }

    setBusy(busy) {
      this.busy = Boolean(busy);
      this.applyActionVisibility();
    }

    startOperation(label) {
      this.startedAt = Date.now();
      this.setBusy(true);
      this.elements.panel.hidden = false;
      this.elements.title.textContent = label || '';
      this.elements.status.textContent = '正在准备下载...';
      this.elements.current.textContent = '';
      this.elements.count.textContent = '0 / 0';
      this.elements.percent.textContent = '0%';
      this.elements.bytes.textContent = '0 B';
      this.elements.speed.textContent = '0 B/s';
      this.setProgress(0);
      this.setWarnings([]);
      this.setFailures([]);
      this.elements.pause.textContent = '暂停';
      this.elements.pause.disabled = true;
      this.elements.cancel.disabled = false;
    }

    setOperationTitle(title) {
      this.elements.title.textContent = title || '';
    }

    setStatus(status) {
      this.elements.status.textContent = status || '';
    }

    setProgress(percent) {
      const value = Utils.clamp(percent, 0, 100);
      this.elements.progressBar.style.width = `${value}%`;
      this.elements.progressTrack.setAttribute('aria-valuenow', String(Math.round(value)));
      this.elements.percent.textContent = `${Math.round(value)}%`;
    }

    setPhaseProgress(percent, label) {
      this.setProgress(percent);
      if (label) this.elements.current.textContent = label;
      this.elements.count.textContent = '生成文件';
    }

    updateQueue(event) {
      const snapshot = event.snapshot;
      this.setProgress(snapshot.percent);
      this.elements.count.textContent = `${snapshot.completed} / ${snapshot.total}`;
      this.elements.bytes.textContent = Utils.formatBytes(snapshot.bytes);
      this.elements.speed.textContent = `${Utils.formatBytes(snapshot.speed)}/s`;
      const current = event.task && event.task.label
        ? event.task.label
        : snapshot.activeLabels.length
          ? snapshot.activeLabels[snapshot.activeLabels.length - 1]
          : '';
      if (current) this.elements.current.textContent = current;
      if (event.type === 'retry') {
        this.elements.status.textContent = `等待重试(${Math.ceil(event.delay / 1000)} 秒)...`;
      } else if (event.type === 'task-start') {
        this.elements.status.textContent = `正在下载:${current}`;
      }
      this.elements.pause.disabled = !['running', 'paused'].includes(snapshot.state);
      this.elements.pause.textContent = snapshot.state === 'paused' ? '继续' : '暂停';
    }

    setWarnings(warnings) {
      this.renderMessages(this.elements.warnings, warnings);
    }

    setFailures(failures) {
      const messages = failures.map((failure) => {
        const label = failure.label || (failure.task && failure.task.label) || '任务';
        return `${label}:${ErrorUtils.toUserMessage(failure.error || failure.reason)}`;
      });
      this.renderMessages(this.elements.failures, messages, failures.length);
    }

    renderMessages(list, messages, totalCount = messages.length) {
      list.textContent = '';
      list.hidden = messages.length === 0;
      const visible = messages.slice(0, 8);
      for (const message of visible) {
        const item = document.createElement('li');
        item.textContent = message;
        list.appendChild(item);
      }
      if (totalCount > visible.length) {
        const item = document.createElement('li');
        item.textContent = `另有 ${totalCount - visible.length} 项,详见下载包中的失败报告。`;
        list.appendChild(item);
      }
    }

    complete(message, failures = []) {
      this.elements.panel.hidden = false;
      this.elements.status.textContent = message || '下载完成。';
      this.setProgress(100);
      if (failures.length) this.setFailures(failures);
      this.elements.current.textContent = '';
      this.elements.pause.disabled = true;
      this.elements.cancel.disabled = true;
      this.setBusy(false);
    }

    fail(message) {
      this.elements.panel.hidden = false;
      this.elements.status.textContent = message || '下载失败。';
      this.elements.current.textContent = '';
      this.elements.pause.disabled = true;
      this.elements.cancel.disabled = true;
      this.setBusy(false);
    }

    cancelled() {
      this.elements.panel.hidden = false;
      this.elements.status.textContent = '下载已取消。活动请求和后续任务均已停止。';
      this.elements.current.textContent = '';
      this.elements.pause.disabled = true;
      this.elements.cancel.disabled = true;
      this.setBusy(false);
    }
  }

  class App {
    constructor() {
      this.settingsManager = new SettingsManager();
      const settings = this.settingsManager.load();
      this.logger = new Logger(settings.debug);
      this.requestManager = new RequestManager({
        timeoutProvider: () => this.settingsManager.get().requestTimeoutMs,
        retryProvider: () => this.settingsManager.get().retryCount,
        logger: this.logger
      });
      this.adapter = new PixivDataAdapter(this.requestManager, this.logger);
      this.binaryFetcher = new BinaryFetcher({
        timeoutProvider: () => this.settingsManager.get().requestTimeoutMs
      });
      this.ui = new UIManager(this.settingsManager);
      this.routeObserver = new RouteObserver();
      this.artworkDownloader = new ArtworkDownloader(this.adapter, this.binaryFetcher, this.logger);
      this.novelDownloader = new NovelDownloader(this.adapter, this.binaryFetcher);
      this.seriesDownloader = new NovelSeriesDownloader(this.adapter, this.binaryFetcher);
      this.currentRoute = { type: 'other', key: 'other' };
      this.currentController = null;
      this.currentQueue = null;
      this.prefetchToken = 0;
      this.unsubscribeSettings = null;
    }

    start() {
      this.ui.mount({
        onAction: () => this.handleAction(),
        onPauseToggle: () => this.togglePause(),
        onCancel: () => this.cancel(),
        onSaveSettings: (settings) => this.settingsManager.save(settings)
      });
      this.unsubscribeSettings = this.settingsManager.subscribe((settings) => {
        this.logger.setEnabled(settings.debug);
        this.ui.updateSettings(settings);
      });
      this.registerMenuCommands();
      this.routeObserver.start((route) => this.handleRoute(route));
      this.logger.info(`已启动 v${SCRIPT_VERSION},适配验证日期 ${VERIFIED_DATE}`);
    }

    registerMenuCommands() {
      if (typeof GM_registerMenuCommand !== 'function') return;
      GM_registerMenuCommand('Pixiv Downloader 设置', () => this.ui.showSettings());
      GM_registerMenuCommand('显示下载进度面板', () => { this.ui.elements.panel.hidden = false; });
    }

    handleRoute(route) {
      this.currentRoute = route;
      this.prefetchToken += 1;
      const token = this.prefetchToken;
      this.logger.info('识别页面', { type: route.type, id: route.id || route.seriesId || '' });
      this.ui.setRoute(route, this.routeLabel(route));
      if (route.type !== 'artwork') return;
      this.adapter.getArtworkInfo(route.id).then((artwork) => {
        if (token !== this.prefetchToken || this.currentRoute.key !== route.key) return;
        let label = '下载原图';
        if (artwork.type === 'ugoira') label = '下载 Ugoira 原始包';
        else if (artwork.type === 'manga') label = '下载全部漫画';
        else if (artwork.pageCount > 1) label = '下载全部图片';
        this.ui.setRoute(route, label);
      }).catch((error) => {
        this.logger.warn('作品类型预取失败', ErrorUtils.serializable(error));
      });
    }

    routeLabel(route) {
      if (route.type === 'artwork') return '下载当前作品';
      if (route.type === 'novel' || route.type === 'novel-series-content') return '下载小说';
      if (route.type === 'novel-series') return '下载整个系列';
      return '下载当前作品';
    }

    async handleAction() {
      if (this.currentController || this.currentRoute.type === 'other') return;
      const route = { ...this.currentRoute };
      const controller = new AbortController();
      this.currentController = controller;
      this.currentQueue = null;
      const context = new DownloadContext(this, controller);
      this.ui.startOperation(this.routeLabel(route));
      try {
        let result;
        if (route.type === 'artwork') result = await this.artworkDownloader.download(route, context);
        else if (route.type === 'novel' || route.type === 'novel-series-content') result = await this.novelDownloader.download(route, context);
        else if (route.type === 'novel-series') result = await this.seriesDownloader.download(route, context);
        else throw new PixivDownloaderError('CONTENT_UNAVAILABLE', '当前页面不支持下载。');
        this.ui.complete(result.message, result.failures || []);
        this.notify(result.message);
      } catch (error) {
        if (ErrorUtils.isAbort(error) || controller.signal.aborted) {
          this.ui.cancelled();
        } else {
          this.logger.error('下载失败', error);
          const message = ErrorUtils.toUserMessage(error);
          this.ui.fail(message);
          this.notify(message, true);
        }
      } finally {
        this.currentController = null;
        this.currentQueue = null;
        this.ui.setBusy(false);
        this.ui.setRoute(this.currentRoute, this.routeLabel(this.currentRoute));
        if (this.currentRoute.type === 'artwork') this.handleRoute(this.currentRoute);
      }
    }

    attachQueue(queue) {
      this.currentQueue = queue;
    }

    togglePause() {
      if (!this.currentQueue) return;
      if (this.currentQueue.state === 'paused') this.currentQueue.resume();
      else this.currentQueue.pause();
    }

    cancel() {
      if (this.currentQueue) this.currentQueue.cancel();
      if (this.currentController && !this.currentController.signal.aborted) this.currentController.abort();
    }

    notify(message, isError = false) {
      if (!this.settingsManager.get().notifications || typeof GM_notification !== 'function') return;
      try {
        GM_notification({
          title: isError ? 'Pixiv Downloader - 下载失败' : 'Pixiv Downloader',
          text: String(message),
          timeout: 6000
        });
      } catch (_) {
        // Notifications are optional and must not affect completed downloads.
      }
    }

    stop() {
      this.cancel();
      this.routeObserver.stop();
      if (this.unsubscribeSettings) this.unsubscribeSettings();
    }
  }

  const TestExports = {
    Config,
    PixivDownloaderError,
    ErrorUtils,
    Utils,
    FilenameUtils,
    PageDetector,
    mapHttpStatus,
    RequestManager,
    PixivDataAdapter,
    NovelParser,
    DownloadQueue,
    BinaryFetcher,
    ZipManager,
    NovelFormatter,
    SeriesFormatter
  };

  if (TEST_MODE) {
    if (typeof module !== 'undefined' && module.exports) module.exports = TestExports;
    return;
  }

  if (typeof window === 'undefined' || typeof document === 'undefined') return;
  if (window.top !== window.self) return;
  const startApp = () => {
    const app = new App();
    app.start();
  };
  if (document.body) startApp();
  else window.addEventListener('DOMContentLoaded', startApp, { once: true });
})();