Pixiv Downloader (插画/漫画)

从Pixiv下载插画和漫画

  1. // ==UserScript==
  2. // @name Pixiv Downloader
  3. // @name:en Pixiv Downloader (Illustration/Manga)
  4. // @name:ja Pixiv Downloader (イラスト/漫画)
  5. // @name:zh-cn Pixiv Downloader (插画/漫画)
  6. // @name:vi Pixiv Downloader (Hình minh họa/Truyện tranh)
  7. // @namespace http://tampermonkey.net/
  8. // @version 2.3.1
  9.  
  10. // @description Tải xuống hình ảnh và truyện tranh từ Pixiv
  11. // @description:en Download illustrations and manga from Pixiv
  12. // @description:ja Pixivからイラストと漫画をダウンロード
  13. // @description:zh-cn 从Pixiv下载插画和漫画
  14. // @description:vi Tải xuống hình minh họa và truyện tranh từ Pixiv
  15. // @match https://www.pixiv.net/*/artworks/*
  16. // @match https://www.pixiv.net/users/*
  17. // @author RenjiYuusei
  18. // @license GPL-3.0-only
  19. // @icon https://www.google.com/s2/favicons?sz=64&domain=pixiv.net
  20. // @grant GM_xmlhttpRequest
  21. // @grant GM_download
  22. // @grant GM_addStyle
  23. // @grant GM_getValue
  24. // @grant GM_setValue
  25. // @grant GM_registerMenuCommand
  26. // @grant GM_notification
  27. // @require https://cdnjs.cloudflare.com/ajax/libs/FileSaver.js/2.0.5/FileSaver.min.js
  28. // @run-at document-end
  29. // @connect pixiv.net
  30. // @connect pximg.net
  31. // @noframes
  32. // ==/UserScript==
  33.  
  34. (function () {
  35. 'use strict';
  36.  
  37. // Configuration
  38. const CONFIG = {
  39. CACHE_DURATION: 24 * 60 * 60 * 1000,
  40. MAX_CONCURRENT: 5, // Tăng số lượng tải xuống đồng thời
  41. NOTIFY_DURATION: 3000,
  42. RETRY_ATTEMPTS: 5, // Tăng số lần thử lại
  43. RETRY_DELAY: 1000,
  44. CHUNK_SIZE: 10, // Tăng số lượng ảnh tải xuống cùng lúc
  45. BATCH_SIZE: 50, // Tăng số lượng artwork tải xuống trong chế độ batch
  46. DOWNLOAD_FORMATS: ['jpg', 'png', 'gif', 'ugoira'], // Hỗ trợ nhiều định dạng
  47. };
  48.  
  49. // Cache và styles
  50. const cache = new Map();
  51. GM_addStyle(`
  52. .pd-container {
  53. position: fixed;
  54. bottom: 20px;
  55. right: 20px;
  56. z-index: 9999;
  57. font-family: Arial, sans-serif;
  58. }
  59. .pd-status, .pd-progress {
  60. background: rgba(33, 33, 33, 0.95);
  61. color: white;
  62. padding: 15px;
  63. border-radius: 10px;
  64. margin-top: 12px;
  65. display: none;
  66. box-shadow: 0 3px 8px rgba(0,0,0,0.3);
  67. }
  68. .pd-progress {
  69. width: 300px;
  70. height: 30px;
  71. background: #444;
  72. padding: 4px;
  73. }
  74. .pd-progress .progress-bar {
  75. height: 100%;
  76. background: linear-gradient(90deg, #2196F3, #00BCD4);
  77. border-radius: 6px;
  78. transition: width 0.4s ease;
  79. }
  80. .pd-batch-dialog {
  81. position: fixed;
  82. top: 50%;
  83. left: 50%;
  84. transform: translate(-50%, -50%);
  85. background: #2c2c2c;
  86. color: #fff;
  87. padding: 25px;
  88. border-radius: 12px;
  89. box-shadow: 0 4px 15px rgba(0,0,0,0.5);
  90. z-index: 10000;
  91. width: 600px;
  92. }
  93. .pd-batch-dialog h3 {
  94. color: #fff;
  95. margin-bottom: 15px;
  96. }
  97. .pd-batch-dialog p {
  98. color: #ddd;
  99. margin-bottom: 10px;
  100. }
  101. .pd-batch-dialog textarea {
  102. width: 100%;
  103. height: 250px;
  104. margin: 12px 0;
  105. padding: 10px;
  106. border: 2px solid #444;
  107. border-radius: 6px;
  108. font-size: 14px;
  109. background: #333;
  110. color: #fff;
  111. }
  112. .pd-batch-dialog button {
  113. padding: 10px 20px;
  114. background: #2196F3;
  115. color: white;
  116. border: none;
  117. border-radius: 6px;
  118. cursor: pointer;
  119. margin-right: 12px;
  120. font-size: 14px;
  121. transition: background 0.3s;
  122. }
  123. .pd-batch-dialog button:hover {
  124. background: #1976D2;
  125. }
  126. .pd-settings-dialog {
  127. position: fixed;
  128. top: 50%;
  129. left: 50%;
  130. transform: translate(-50%, -50%);
  131. background: #2c2c2c;
  132. color: #fff;
  133. padding: 25px;
  134. border-radius: 12px;
  135. box-shadow: 0 4px 15px rgba(0,0,0,0.5);
  136. z-index: 10000;
  137. width: 500px;
  138. }
  139. .pd-settings-dialog h3 {
  140. color: #fff;
  141. margin-bottom: 15px;
  142. }
  143. .pd-settings-item {
  144. margin: 15px 0;
  145. }
  146. .pd-settings-item label {
  147. display: block;
  148. margin-bottom: 5px;
  149. font-weight: bold;
  150. color: #fff;
  151. }
  152. .pd-settings-item small {
  153. color: #aaa;
  154. display: block;
  155. margin-top: 5px;
  156. }
  157. .pd-settings-item input[type="text"],
  158. .pd-settings-item select {
  159. width: 100%;
  160. padding: 8px;
  161. border: 2px solid #444;
  162. border-radius: 6px;
  163. background: #333;
  164. color: #fff;
  165. }
  166. .pd-settings-item select option {
  167. background: #333;
  168. color: #fff;
  169. }
  170. `);
  171.  
  172. // Utilities
  173. const utils = {
  174. sleep: ms => new Promise(resolve => setTimeout(resolve, ms)),
  175.  
  176. retry: async (fn, attempts = CONFIG.RETRY_ATTEMPTS) => {
  177. for (let i = 0; i < attempts; i++) {
  178. try {
  179. return await fn();
  180. } catch (err) {
  181. if (i === attempts - 1) throw err;
  182. await utils.sleep(CONFIG.RETRY_DELAY * (i + 1));
  183. }
  184. }
  185. },
  186.  
  187. fetch: async (url, opts = {}) => {
  188. const cached = cache.get(url);
  189. if (cached?.timestamp > Date.now() - CONFIG.CACHE_DURATION) {
  190. return cached.data;
  191. }
  192.  
  193. return new Promise((resolve, reject) => {
  194. GM_xmlhttpRequest({
  195. method: opts.method || 'GET',
  196. url,
  197. responseType: opts.responseType || 'json',
  198. headers: {
  199. Referer: 'https://www.pixiv.net/',
  200. Accept: 'application/json',
  201. 'X-Requested-With': 'XMLHttpRequest',
  202. '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',
  203. },
  204. withCredentials: false,
  205. onload: res => {
  206. if (res.status === 200) {
  207. const data = opts.responseType === 'blob' ? res.response : JSON.parse(res.responseText);
  208. cache.set(url, { data, timestamp: Date.now() });
  209. resolve(data);
  210. } else reject(new Error(`HTTP ${res.status}: ${res.statusText}`));
  211. },
  212. onerror: reject,
  213. ontimeout: () => reject(new Error('Request timed out')),
  214. timeout: 30000,
  215. });
  216. });
  217. },
  218.  
  219. extractId: input => {
  220. const match = input.match(/artworks\/(\d+)/) || input.match(/^(\d+)$/);
  221. return match ? match[1] : null;
  222. },
  223.  
  224. ui: {
  225. container: null,
  226. init: () => {
  227. utils.ui.container = document.createElement('div');
  228. utils.ui.container.className = 'pd-container';
  229. document.body.appendChild(utils.ui.container);
  230. utils.ui.status.init();
  231. utils.ui.progress.init();
  232. },
  233.  
  234. notify: (msg, type = 'info') =>
  235. GM_notification({
  236. text: msg,
  237. title: 'Pixiv Downloader',
  238. timeout: CONFIG.NOTIFY_DURATION,
  239. }),
  240.  
  241. status: {
  242. el: null,
  243. init: () => {
  244. utils.ui.status.el = document.createElement('div');
  245. utils.ui.status.el.className = 'pd-status';
  246. utils.ui.container.appendChild(utils.ui.status.el);
  247. },
  248. show: msg => {
  249. utils.ui.status.el.textContent = msg;
  250. utils.ui.status.el.style.display = 'block';
  251. },
  252. hide: () => (utils.ui.status.el.style.display = 'none'),
  253. },
  254.  
  255. progress: {
  256. el: null,
  257. bar: null,
  258. init: () => {
  259. const container = document.createElement('div');
  260. container.className = 'pd-progress';
  261. const bar = document.createElement('div');
  262. bar.className = 'progress-bar';
  263. container.appendChild(bar);
  264. utils.ui.container.appendChild(container);
  265. utils.ui.progress.el = container;
  266. utils.ui.progress.bar = bar;
  267. },
  268. update: pct => {
  269. utils.ui.progress.el.style.display = 'block';
  270. utils.ui.progress.bar.style.width = `${pct}%`;
  271. },
  272. hide: () => (utils.ui.progress.el.style.display = 'none'),
  273. },
  274.  
  275. showSettingsDialog: () => {
  276. const dialog = document.createElement('div');
  277. dialog.className = 'pd-settings-dialog';
  278. dialog.innerHTML = `
  279. <h3>Settings</h3>
  280. <div class="pd-settings-item">
  281. <label>Filename Format:</label>
  282. <input type="text" id="filenameFormat" value="${GM_getValue('filenameFormat', '{artist} - {title} ({id})_{idx}')}">
  283. <small>Available tags: {artist}, {title}, {id}, {idx}, {ext}</small>
  284. </div>
  285. <div>
  286. <button class="save">Save</button>
  287. <button class="cancel">Cancel</button>
  288. </div>
  289. `;
  290.  
  291. document.body.appendChild(dialog);
  292.  
  293. const saveBtn = dialog.querySelector('.save');
  294. const cancelBtn = dialog.querySelector('.cancel');
  295.  
  296. saveBtn.addEventListener('click', () => {
  297. const format = dialog.querySelector('#filenameFormat').value;
  298. GM_setValue('filenameFormat', format);
  299. utils.ui.notify('Settings saved!');
  300. dialog.remove();
  301. });
  302.  
  303. cancelBtn.addEventListener('click', () => dialog.remove());
  304. },
  305.  
  306. showBatchDialog: () => {
  307. const dialog = document.createElement('div');
  308. dialog.className = 'pd-batch-dialog';
  309. dialog.innerHTML = `
  310. <h3>Batch Download</h3>
  311. <p>Enter the ID or URL of the artwork (one link per line):</p>
  312. <textarea placeholder="Example:&#13;&#10;8229272&#13;&#10;https://www.pixiv.net/en/artworks/12345678"></textarea>
  313. <div>
  314. <button class="download">Download</button>
  315. <button class="cancel">Cancel</button>
  316. </div>
  317. <div class="pd-batch-status"></div>
  318. `;
  319.  
  320. document.body.appendChild(dialog);
  321.  
  322. const textarea = dialog.querySelector('textarea');
  323. const downloadBtn = dialog.querySelector('.download');
  324. const cancelBtn = dialog.querySelector('.cancel');
  325.  
  326. downloadBtn.addEventListener('click', async () => {
  327. const links = textarea.value.split('\n').filter(Boolean);
  328. const ids = links.map(link => utils.extractId(link.trim())).filter(Boolean);
  329.  
  330. if (ids.length === 0) {
  331. utils.ui.notify('Invalid ID!', 'error');
  332. return;
  333. }
  334.  
  335. dialog.remove();
  336. await app.batchDownloadByIds(ids);
  337. });
  338.  
  339. cancelBtn.addEventListener('click', () => dialog.remove());
  340. },
  341. },
  342. };
  343.  
  344. // Main application
  345. const app = {
  346. async getIllustData(id) {
  347. const data = await utils.retry(() => utils.fetch(`https://www.pixiv.net/ajax/illust/${id}`));
  348. return data.body;
  349. },
  350.  
  351. getFilename(data, idx = 0) {
  352. const format = GM_getValue('filenameFormat', '{artist} - {title} ({id})_{idx}');
  353. const sanitize = str => str.replace(/[<>:"/\\|?*]/g, '_').trim();
  354. 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());
  355. },
  356.  
  357. async downloadSingle(url, filename) {
  358. const blob = await utils.retry(() => utils.fetch(url, { responseType: 'blob' }));
  359. saveAs(blob, filename);
  360. },
  361.  
  362. async downloadChunk(tasks) {
  363. return Promise.all(tasks.map(task => task()));
  364. },
  365.  
  366. async download(illust) {
  367. let completed = 0;
  368. const total = illust.pageCount;
  369.  
  370. const downloadTasks = Array.from({ length: total }, (_, i) => async () => {
  371. const url = illust.urls.original.replace('_p0', `_p${i}`);
  372. const blob = await utils.retry(() => utils.fetch(url, { responseType: 'blob' }));
  373.  
  374. completed++;
  375. utils.ui.status.show(`Downloading: ${completed}/${total}`);
  376. utils.ui.progress.update((completed / total) * 100);
  377.  
  378. const filename = `${app.getFilename(illust, i)}`;
  379. saveAs(blob, filename);
  380. });
  381.  
  382. for (let i = 0; i < downloadTasks.length; i += CONFIG.CHUNK_SIZE) {
  383. const chunk = downloadTasks.slice(i, i + CONFIG.CHUNK_SIZE);
  384. await app.downloadChunk(chunk).catch(err => {
  385. utils.ui.notify(`Error: ${err.message}`, 'error');
  386. throw err;
  387. });
  388. await utils.sleep(500);
  389. }
  390.  
  391. utils.ui.notify('Download completed!', 'success');
  392. utils.ui.status.hide();
  393. utils.ui.progress.hide();
  394. },
  395.  
  396. async batchDownloadByIds(ids) {
  397. let completed = 0;
  398. const total = ids.length;
  399. const failedIds = [];
  400.  
  401. utils.ui.status.show(`Batch download started: 0/${total}`);
  402.  
  403. for (const id of ids) {
  404. try {
  405. const illust = await app.getIllustData(id);
  406. for (let i = 0; i < illust.pageCount; i++) {
  407. const url = illust.urls.original.replace('_p0', `_p${i}`);
  408. const blob = await utils.retry(() => utils.fetch(url, { responseType: 'blob' }));
  409. const filename = `${app.getFilename(illust, i)}`;
  410. saveAs(blob, filename);
  411. }
  412.  
  413. completed++;
  414. utils.ui.status.show(`Batch download progress: ${completed}/${total}`);
  415. } catch (err) {
  416. console.error(`Error downloading ${id}:`, err);
  417. utils.ui.notify(`Error downloading artwork ${id}: ${err.message}`, 'error');
  418. failedIds.push(id);
  419. }
  420. await utils.sleep(1000);
  421. }
  422.  
  423. if (failedIds.length > 0) {
  424. console.log('Failed downloads:', failedIds);
  425. utils.ui.notify(`Some downloads failed. Check console for details.`, 'warning');
  426. }
  427.  
  428. utils.ui.notify(`Batch download completed! Downloaded ${completed} artworks`, 'success');
  429. utils.ui.status.hide();
  430. },
  431.  
  432. init() {
  433. utils.ui.init();
  434.  
  435. // Single artwork download
  436. GM_registerMenuCommand('Download Artwork', async () => {
  437. try {
  438. utils.ui.status.show('Loading data...');
  439. const illust = await app.getIllustData(location.pathname.split('/').pop());
  440. await app.download(illust);
  441. } catch (err) {
  442. utils.ui.notify(`Error: ${err.message}`, 'error');
  443. utils.ui.status.hide();
  444. utils.ui.progress.hide();
  445. }
  446. });
  447.  
  448. // Batch download
  449. GM_registerMenuCommand('Batch Download', () => {
  450. utils.ui.showBatchDialog();
  451. });
  452.  
  453. // Settings
  454. GM_registerMenuCommand('Settings', () => {
  455. utils.ui.showSettingsDialog();
  456. });
  457. },
  458. };
  459.  
  460. // Start
  461. if (document.readyState === 'loading') {
  462. document.addEventListener('DOMContentLoaded', () => app.init());
  463. } else {
  464. app.init();
  465. }
  466. })();
  467.