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