Pixiv Downloader (插画/漫画)

从Pixiv下载插画和漫画

目前为 2024-12-05 提交的版本,查看 最新版本

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