- // ==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.3.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: 5, // Tăng số lượng tải xuống đồng thời
- NOTIFY_DURATION: 3000,
- RETRY_ATTEMPTS: 5, // Tăng số lần thử lại
- RETRY_DELAY: 1000,
- CHUNK_SIZE: 10, // Tăng số lượng ảnh tải xuống cùng lúc
- BATCH_SIZE: 50, // Tăng số lượng artwork tải xuống trong chế độ batch
- DOWNLOAD_FORMATS: ['jpg', 'png', 'gif', 'ugoira'], // Hỗ trợ nhiều định dạng
- };
-
- // Cache và 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.95);
- color: white;
- padding: 15px;
- border-radius: 10px;
- margin-top: 12px;
- display: none;
- box-shadow: 0 3px 8px rgba(0,0,0,0.3);
- }
- .pd-progress {
- width: 300px;
- height: 30px;
- background: #444;
- padding: 4px;
- }
- .pd-progress .progress-bar {
- height: 100%;
- background: linear-gradient(90deg, #2196F3, #00BCD4);
- border-radius: 6px;
- transition: width 0.4s ease;
- }
- .pd-batch-dialog {
- position: fixed;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- background: #2c2c2c;
- color: #fff;
- padding: 25px;
- border-radius: 12px;
- box-shadow: 0 4px 15px rgba(0,0,0,0.5);
- z-index: 10000;
- width: 600px;
- }
- .pd-batch-dialog h3 {
- color: #fff;
- margin-bottom: 15px;
- }
- .pd-batch-dialog p {
- color: #ddd;
- margin-bottom: 10px;
- }
- .pd-batch-dialog textarea {
- width: 100%;
- height: 250px;
- margin: 12px 0;
- padding: 10px;
- border: 2px solid #444;
- border-radius: 6px;
- font-size: 14px;
- background: #333;
- color: #fff;
- }
- .pd-batch-dialog button {
- padding: 10px 20px;
- background: #2196F3;
- color: white;
- border: none;
- border-radius: 6px;
- cursor: pointer;
- margin-right: 12px;
- font-size: 14px;
- transition: background 0.3s;
- }
- .pd-batch-dialog button:hover {
- background: #1976D2;
- }
- .pd-settings-dialog {
- position: fixed;
- top: 50%;
- left: 50%;
- transform: translate(-50%, -50%);
- background: #2c2c2c;
- color: #fff;
- padding: 25px;
- border-radius: 12px;
- box-shadow: 0 4px 15px rgba(0,0,0,0.5);
- z-index: 10000;
- width: 500px;
- }
- .pd-settings-dialog h3 {
- color: #fff;
- margin-bottom: 15px;
- }
- .pd-settings-item {
- margin: 15px 0;
- }
- .pd-settings-item label {
- display: block;
- margin-bottom: 5px;
- font-weight: bold;
- color: #fff;
- }
- .pd-settings-item small {
- color: #aaa;
- display: block;
- margin-top: 5px;
- }
- .pd-settings-item input[type="text"],
- .pd-settings-item select {
- width: 100%;
- padding: 8px;
- border: 2px solid #444;
- border-radius: 6px;
- background: #333;
- color: #fff;
- }
- .pd-settings-item select option {
- background: #333;
- color: #fff;
- }
- `);
-
- // 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 => {
- 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'),
- },
-
- showSettingsDialog: () => {
- const dialog = document.createElement('div');
- dialog.className = 'pd-settings-dialog';
- dialog.innerHTML = `
- <h3>Settings</h3>
- <div class="pd-settings-item">
- <label>Filename Format:</label>
- <input type="text" id="filenameFormat" value="${GM_getValue('filenameFormat', '{artist} - {title} ({id})_{idx}')}">
- <small>Available tags: {artist}, {title}, {id}, {idx}, {ext}</small>
- </div>
- <div>
- <button class="save">Save</button>
- <button class="cancel">Cancel</button>
- </div>
- `;
-
- document.body.appendChild(dialog);
-
- const saveBtn = dialog.querySelector('.save');
- const cancelBtn = dialog.querySelector('.cancel');
-
- saveBtn.addEventListener('click', () => {
- const format = dialog.querySelector('#filenameFormat').value;
- GM_setValue('filenameFormat', format);
- utils.ui.notify('Settings saved!');
- dialog.remove();
- });
-
- cancelBtn.addEventListener('click', () => dialog.remove());
- },
-
- 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: 8229272 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');
-
- 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}', data.urls.original.split('.').pop());
- },
-
- 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)}`;
- 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;
- const failedIds = [];
-
- utils.ui.status.show(`Batch download started: 0/${total}`);
-
- for (const id of ids) {
- try {
- const illust = await app.getIllustData(id);
-
- for (let i = 0; i < illust.pageCount; i++) {
- const url = illust.urls.original.replace('_p0', `_p${i}`);
- const blob = await utils.retry(() => utils.fetch(url, { responseType: 'blob' }));
- const filename = `${app.getFilename(illust, i)}`;
- saveAs(blob, filename);
- }
-
- 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');
- failedIds.push(id);
- }
- await utils.sleep(1000);
- }
-
- if (failedIds.length > 0) {
- console.log('Failed downloads:', failedIds);
- utils.ui.notify(`Some downloads failed. Check console for details.`, 'warning');
- }
-
- 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();
- });
-
- // Settings
- GM_registerMenuCommand('Settings', () => {
- utils.ui.showSettingsDialog();
- });
- },
- };
-
- // Start
- if (document.readyState === 'loading') {
- document.addEventListener('DOMContentLoaded', () => app.init());
- } else {
- app.init();
- }
- })();
-