Pixiv Downloader

Tải xuống hình ảnh và truyện tranh từ Pixiv

目前為 2024-12-05 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Pixiv Downloader
// @name:en      Pixiv Downloader (Illustration/Manga)
// @name:ja      Pixiv Downloader (イラスト/漫画)
// @name:zh-cn   Pixiv Downloader (插画/漫画)
// @name:vi      Pixiv Downloader (Hình minh họa/Truyện tranh)
// @namespace    http://tampermonkey.net/
// @version      2.2.0
// @description  Tải xuống hình ảnh và truyện tranh từ Pixiv
// @description:en Download illustrations and manga from Pixiv
// @description:ja Pixivからイラストと漫画をダウンロード
// @description:zh-cn 从Pixiv下载插画和漫画
// @description:vi Tải xuống hình minh họa và truyện tranh từ Pixiv
// @match        https://www.pixiv.net/en/artworks/*
// @match        https://www.pixiv.net/users/*
// @author       RenjiYuusei
// @license      GPL-3.0-only
// @icon         https://www.google.com/s2/favicons?sz=64&domain=pixiv.net
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @grant        GM_addStyle
// @grant        GM_getValue
// @grant        GM_setValue
// @grant        GM_registerMenuCommand
// @grant        GM_notification
// @require      https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
// @run-at       document-end
// @connect      pixiv.net
// @connect      pximg.net
// @noframes
// ==/UserScript==

(function () {
	'use strict';

	// Configuration
	const CONFIG = {
		CACHE_DURATION: 24 * 60 * 60 * 1000,
		MAX_CONCURRENT: 3,
		NOTIFY_DURATION: 3000,
		RETRY_ATTEMPTS: 3,
		RETRY_DELAY: 1000,
		CHUNK_SIZE: 5, // Number of images to download at once
		BATCH_SIZE: 20, // Number of artworks to download in batch mode
	};

	// Cache and styles
	const cache = new Map();
	GM_addStyle(`
        .pd-container {
            position: fixed;
            bottom: 20px;
            right: 20px;
            z-index: 9999;
            font-family: Arial, sans-serif;
        }
        .pd-status, .pd-progress {
            background: rgba(33, 33, 33, 0.9);
            color: white;
            padding: 12px;
            border-radius: 8px;
            margin-top: 10px;
            display: none;
            box-shadow: 0 2px 5px rgba(0,0,0,0.2);
        }
        .pd-progress {
            width: 250px;
            height: 24px;
            background: #444;
            padding: 3px;
        }
        .pd-progress .progress-bar {
            height: 100%;
            background: linear-gradient(90deg, #2196F3, #00BCD4);
            border-radius: 4px;
            transition: width 0.3s ease;
        }
        .pd-batch-dialog {
            position: fixed;
            top: 50%;
            left: 50%;
            transform: translate(-50%, -50%);
            background: white;
            padding: 20px;
            border-radius: 8px;
            box-shadow: 0 2px 10px rgba(0,0,0,0.3);
            z-index: 10000;
            width: 500px;
        }
        .pd-batch-dialog textarea {
            width: 100%;
            height: 200px;
            margin: 10px 0;
            padding: 8px;
            border: 1px solid #ddd;
            border-radius: 4px;
        }
        .pd-batch-dialog button {
            padding: 8px 16px;
            background: #2196F3;
            color: white;
            border: none;
            border-radius: 4px;
            cursor: pointer;
            margin-right: 10px;
        }
        .pd-batch-dialog button:hover {
            background: #1976D2;
        }
        .pd-batch-status {
            margin-top: 10px;
            color: #666;
        }
    `);

	// Utilities
	const utils = {
		sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),

		retry: async (fn, attempts = CONFIG.RETRY_ATTEMPTS) => {
			for (let i = 0; i < attempts; i++) {
				try {
					return await fn();
				} catch (err) {
					if (i === attempts - 1) throw err;
					await utils.sleep(CONFIG.RETRY_DELAY * (i + 1));
				}
			}
		},

		fetch: async (url, opts = {}) => {
			const cached = cache.get(url);
			if (cached?.timestamp > Date.now() - CONFIG.CACHE_DURATION) {
				return cached.data;
			}

			return new Promise((resolve, reject) => {
				GM_xmlhttpRequest({
					method: opts.method || 'GET',
					url,
					responseType: opts.responseType || 'json',
					headers: {
						Referer: 'https://www.pixiv.net/',
						Accept: 'application/json',
						'X-Requested-With': 'XMLHttpRequest',
						'User-Agent': 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36',
					},
					withCredentials: false,
					onload: res => {
						if (res.status === 200) {
							const data = opts.responseType === 'blob' ? res.response : JSON.parse(res.responseText);
							cache.set(url, { data, timestamp: Date.now() });
							resolve(data);
						} else reject(new Error(`HTTP ${res.status}: ${res.statusText}`));
					},
					onerror: reject,
					ontimeout: () => reject(new Error('Request timed out')),
					timeout: 30000,
				});
			});
		},

		extractId: input => {
			// Handle both full URLs and direct IDs
			const match = input.match(/artworks\/(\d+)/) || input.match(/^(\d+)$/);
			return match ? match[1] : null;
		},

		ui: {
			container: null,
			init: () => {
				utils.ui.container = document.createElement('div');
				utils.ui.container.className = 'pd-container';
				document.body.appendChild(utils.ui.container);
				utils.ui.status.init();
				utils.ui.progress.init();
			},

			notify: (msg, type = 'info') =>
				GM_notification({
					text: msg,
					title: 'Pixiv Downloader',
					timeout: CONFIG.NOTIFY_DURATION,
				}),

			status: {
				el: null,
				init: () => {
					utils.ui.status.el = document.createElement('div');
					utils.ui.status.el.className = 'pd-status';
					utils.ui.container.appendChild(utils.ui.status.el);
				},
				show: msg => {
					utils.ui.status.el.textContent = msg;
					utils.ui.status.el.style.display = 'block';
				},
				hide: () => (utils.ui.status.el.style.display = 'none'),
			},

			progress: {
				el: null,
				bar: null,
				init: () => {
					const container = document.createElement('div');
					container.className = 'pd-progress';
					const bar = document.createElement('div');
					bar.className = 'progress-bar';
					container.appendChild(bar);
					utils.ui.container.appendChild(container);
					utils.ui.progress.el = container;
					utils.ui.progress.bar = bar;
				},
				update: pct => {
					utils.ui.progress.el.style.display = 'block';
					utils.ui.progress.bar.style.width = `${pct}%`;
				},
				hide: () => (utils.ui.progress.el.style.display = 'none'),
			},

			showBatchDialog: () => {
				const dialog = document.createElement('div');
				dialog.className = 'pd-batch-dialog';
				dialog.innerHTML = `
                    <h3>Batch Download</h3>
                    <p>Enter the ID or URL of the artwork (one link per line):</p>
                    <textarea placeholder="Example:&#13;&#10;8229272&#13;&#10;https://www.pixiv.net/en/artworks/12345678"></textarea>
                    <div>
                        <button class="download">Download</button>
                        <button class="cancel">Cancel</button>
                    </div>
                    <div class="pd-batch-status"></div>
                `;

				document.body.appendChild(dialog);

				const textarea = dialog.querySelector('textarea');
				const downloadBtn = dialog.querySelector('.download');
				const cancelBtn = dialog.querySelector('.cancel');
				const status = dialog.querySelector('.pd-batch-status');

				downloadBtn.addEventListener('click', async () => {
					const links = textarea.value.split('\n').filter(Boolean);
					const ids = links.map(link => utils.extractId(link.trim())).filter(Boolean);

					if (ids.length === 0) {
						utils.ui.notify('Invalid ID!', 'error');
						return;
					}

					dialog.remove();
					await app.batchDownloadByIds(ids);
				});

				cancelBtn.addEventListener('click', () => dialog.remove());
			},
		},
	};

	// Main application
	const app = {
		async getIllustData(id) {
			const data = await utils.retry(() => utils.fetch(`https://www.pixiv.net/ajax/illust/${id}`));
			return data.body;
		},

		getFilename(data, idx = 0) {
			const format = GM_getValue('filenameFormat', '{artist} - {title} ({id})_{idx}');
			const sanitize = str => str.replace(/[<>:"/\\|?*]/g, '_').trim();
			return format.replace('{artist}', sanitize(data.userName)).replace('{title}', sanitize(data.title)).replace('{id}', data.id).replace('{idx}', String(idx).padStart(3, '0')).replace('{ext}', 'jpg');
		},

		async downloadSingle(url, filename) {
			const blob = await utils.retry(() => utils.fetch(url, { responseType: 'blob' }));
			saveAs(blob, filename);
		},

		async downloadChunk(tasks) {
			return Promise.all(tasks.map(task => task()));
		},

		async download(illust) {
			let completed = 0;
			const total = illust.pageCount;

			const downloadTasks = Array.from({ length: total }, (_, i) => async () => {
				const url = illust.urls.original.replace('_p0', `_p${i}`);
				const blob = await utils.retry(() => utils.fetch(url, { responseType: 'blob' }));

				completed++;
				utils.ui.status.show(`Downloading: ${completed}/${total}`);
				utils.ui.progress.update((completed / total) * 100);

				const filename = `${app.getFilename(illust, i)}.jpg`;
				saveAs(blob, filename);
			});

			for (let i = 0; i < downloadTasks.length; i += CONFIG.CHUNK_SIZE) {
				const chunk = downloadTasks.slice(i, i + CONFIG.CHUNK_SIZE);
				await app.downloadChunk(chunk).catch(err => {
					utils.ui.notify(`Error: ${err.message}`, 'error');
					throw err;
				});
				await utils.sleep(500);
			}

			utils.ui.notify('Download completed!', 'success');
			utils.ui.status.hide();
			utils.ui.progress.hide();
		},

		async batchDownloadByIds(ids) {
			let completed = 0;
			const total = ids.length;

			utils.ui.status.show(`Batch download started: 0/${total}`);

			for (const id of ids) {
				try {
					const illust = await app.getIllustData(id);
					await app.download(illust);
					completed++;
					utils.ui.status.show(`Batch download progress: ${completed}/${total}`);
				} catch (err) {
					console.error(`Error downloading ${id}:`, err);
					utils.ui.notify(`Error downloading artwork ${id}: ${err.message}`, 'error');
				}
				await utils.sleep(1000);
			}

			utils.ui.notify(`Batch download completed! Downloaded ${completed} artworks`, 'success');
			utils.ui.status.hide();
		},

		init() {
			utils.ui.init();

			// Single artwork download
			GM_registerMenuCommand('Download Artwork', async () => {
				try {
					utils.ui.status.show('Loading data...');
					const illust = await app.getIllustData(location.pathname.split('/').pop());
					await app.download(illust);
				} catch (err) {
					utils.ui.notify(`Error: ${err.message}`, 'error');
					utils.ui.status.hide();
					utils.ui.progress.hide();
				}
			});

			// Batch download
			GM_registerMenuCommand('Batch Download', () => {
				utils.ui.showBatchDialog();
			});
		},
	};

	// Start
	if (document.readyState === 'loading') {
		document.addEventListener('DOMContentLoaded', () => app.init());
	} else {
		app.init();
	}
})();