下载 Pixiv 插画、漫画、小说和完整小说系列。
// ==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(/</g, '<') .replace(/>/g, '>') .replace(/"/g, '"') .replace(/'/g, "'") .replace(/&/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\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, '&') .replace(/"/g, '"') .replace(/</g, '<') .replace(/>/g, '>'); } static escapeHtmlText(value) { return String(value == null ? '' : value) .replace(/&/g, '&') .replace(/</g, '<') .replace(/>/g, '>'); } } 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 }); })();