Jann / Pixiv Downloader (Illustrations/Manga/Ugoira)

// ==UserScript==
// @name         Pixiv Downloader (Illustrations/Manga/Ugoira)
// @name:vi      Trình tải xuống Pixiv (Minh họa/Truyện tranh/Ugoira)
// @name:zh-cn   Pixiv 下载器(插图/漫画/动图)
// @name:zh-tw   Pixiv 下載器(插圖/漫畫/動圖)
// @name:ja      ピクシブダウンローダー(イラスト/マンガ/うごイラ)
// @namespace    http://tampermonkey.net/
// @version      1.3.1
// @description  Download illustrations, manga, and ugoira from Pixiv with optimized speed, ZIP support for multi-page works, and GIF conversion for ugoira
// @description:vi Tải xuống hình minh họa, truyện tranh và ugoira từ Pixiv với tốc độ tối ưu, có hỗ trợ ZIP cho các tác phẩm nhiều trang và chuyển đổi GIF cho ugoira
// @description:zh-cn 以优化的速度从 Pixiv 下载插图、漫画和动图,ZIP 支持多页作品,动图转换为 GIF
// @description:zh-tw 以最佳化的速度從 Pixiv 下載插圖、漫畫和動圖,ZIP 支援多頁作品,動圖轉換為 GIF
// @description:ja    最適化された速度でPixivからイラスト、漫画、うごイラをダウンロード、複数ページの作品にはZIPをサポート、うごイラはGIFに変換
// @match        https://www.pixiv.net/en/artworks/*
// @author       Jann
// @license      GPL-3.0-only
// @icon         https://t0.gstatic.com/faviconV2?client=SOCIAL&type=FAVICON&fallback_opts=TYPE,SIZE,URL&url=https://www.pixiv.net&size=64
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @grant        GM_addStyle
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_registerMenuCommand
// @grant        GM_unregisterMenuCommand
// @grant        GM_notification
// @grant        GM_setClipboard
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js
// ==/UserScript==

(function () {
  'use strict';

  const CACHE_DURATION = 24 * 60 * 60 * 1000; // 24 hours
  const MAX_CONCURRENT_DOWNLOADS = 3;
  const NOTIFICATION_DURATION = 3000; // 3 seconds

  const cache = new Map();

  const styles = `
        #pixiv-downloader-status {
            position: fixed;
            bottom: 20px;
            right: 20px;
            z-index: 9999;
            padding: 10px;
            background-color: rgba(0, 0, 0, 0.7);
            color: white;
            border-radius: 5px;
            font-size: 14px;
            display: none;
        }
    `;

  GM_addStyle(styles);

  const getCachedData = (key) => {
    const cachedData = cache.get(key);
    if (cachedData && (Date.now() - cachedData.timestamp < CACHE_DURATION)) {
      return cachedData.data;
    }
    return null;
  };

  const setCachedData = (key, data) => {
    cache.set(key, {
      data,
      timestamp: Date.now()
    });
  };

  const fetchWithCache = (url, options = {}) => {
    return new Promise((resolve, reject) => {
      const cachedData = getCachedData(url);
      if (cachedData) {
        resolve(cachedData);
        return;
      }

      GM_xmlhttpRequest({
        method: options.method || "GET",
        url: url,
        responseType: options.responseType || "json",
        headers: {
          "Referer": "https://www.pixiv.net/"
        },
        onload: (response) => {
          if (response.status === 200) {
            const data = options.responseType === "blob" ? response.response : JSON.parse(response.responseText);
            setCachedData(url, data);
            resolve(data);
          }
          else {
            reject(`Failed to fetch: ${response.status}`);
          }
        },
        onerror: (error) => reject(`Error fetching: ${error}`)
      });
    });
  };

  const getIllustData = async () => {
    const illustId = window.location.pathname.split('/').pop();
    const url = `https://www.pixiv.net/ajax/illust/${illustId}`;
    const data = await fetchWithCache(url);
    return data.body;
  };

  const createStatusElement = () => {
    const status = document.createElement('div');
    status.id = 'pixiv-downloader-status';
    document.body.appendChild(status);
    return status;
  };

  const updateStatus = (message) => {
    const status = document.getElementById('pixiv-downloader-status');
    status.textContent = message;
    status.style.display = 'block';
  };

  const hideStatus = () => {
    const status = document.getElementById('pixiv-downloader-status');
    status.style.display = 'none';
  };

  const showNotification = (message, type = 'info') => {
    GM_notification({
      text: message,
      title: 'Pixiv Downloader',
      timeout: NOTIFICATION_DURATION,
      image: type === 'success' ? 'https://i.imgur.com/oUiCS7Y.png' : type === 'error' ? 'https://i.imgur.com/LxPNwvr.png' : null
    });
  };

  const toggleUseOriginalFilename = () => {
    const currentValue = GM_getValue('useOriginalFilename', false);
    GM_setValue('useOriginalFilename', !currentValue);
    updateMenuCommands();
    showNotification(`Use Original Filename: ${!currentValue ? 'On' : 'Off'}`);
  };

  const getFilename = (illustData, index = 0) => {
    const useOriginalFilename = GM_getValue('useOriginalFilename', false);
    if (useOriginalFilename) {
      return `${illustData.id}${index > 0 ? `_p${index}` : ''}.${illustData.urls.original.split('.').pop()}`;
    }
    else {
      const artistName = illustData.userName.replace(/[^\w\s-]/gi, '');
      const artworkTitle = illustData.title.replace(/[^\w\s-]/gi, '');
      return `${artistName} - ${artworkTitle} (${illustData.id})${index > 0 ? `_p${index}` : ''}.${illustData.urls.original.split('.').pop()}`;
    }
  };

  const downloadSinglePage = async (illustData) => {
    updateStatus('Downloading single image...');
    const url = illustData.urls.original;
    const filename = getFilename(illustData);

    try {
      const response = await fetchWithCache(url, {
        responseType: "blob"
      });
      const blob = new Blob([response], {
        type: 'image/jpeg'
      });
      const downloadUrl = URL.createObjectURL(blob);

      GM_download({
        url: downloadUrl,
        name: filename,
        saveAs: true,
        onload: () => {
          URL.revokeObjectURL(downloadUrl);
          showNotification('Download complete!', 'success');
          hideStatus();
        },
        onerror: (error) => {
          URL.revokeObjectURL(downloadUrl);
          showNotification(`Download failed: ${error.message}`, 'error');
          hideStatus();
        }
      });
    }
    catch (error) {
      console.error('Error downloading image:', error);
      showNotification(`Download failed: ${error.message}`, 'error');
      hideStatus();
    }
  };

  const downloadMultiplePages = async (illustData) => {
    updateStatus('Downloading multiple pages...');
    const zip = new JSZip();
    const downloadPromises = [];
    let completedDownloads = 0;

    for (let i = 0; i < illustData.pageCount; i++) {
      const url = illustData.urls.original.replace('_p0', `_p${i}`);
      downloadPromises.push(
        fetchWithCache(url, {
          responseType: "blob"
        })
        .then(blob => {
          completedDownloads++;
          updateStatus(`Downloaded page ${completedDownloads}/${illustData.pageCount}`);
          return zip.file(getFilename(illustData, i), blob);
        })
        .catch(error => {
          console.error(`Failed to download page ${i+1}:`, error);
          showNotification(`Failed to download page ${i+1}: ${error.message}`, 'error');
        })
      );

      if (downloadPromises.length === MAX_CONCURRENT_DOWNLOADS) {
        await Promise.all(downloadPromises);
        downloadPromises.length = 0;
      }
    }

    await Promise.all(downloadPromises);

    if (completedDownloads === illustData.pageCount) {
      updateStatus('Generating ZIP file...');
      const content = await zip.generateAsync({
        type: "blob"
      });
      const zipUrl = URL.createObjectURL(content);
      const zipFilename = getFilename(illustData).replace(/\.\w+$/, '.zip');

      GM_download({
        url: zipUrl,
        name: zipFilename,
        saveAs: true,
        onload: () => {
          URL.revokeObjectURL(zipUrl);
          showNotification('Download complete!', 'success');
          hideStatus();
        },
        onerror: (error) => {
          URL.revokeObjectURL(zipUrl);
          showNotification(`Download failed: ${error.message}`, 'error');
          hideStatus();
        }
      });
    }
    else {
      showNotification(`Download incomplete. Only ${completedDownloads}/${illustData.pageCount} pages were downloaded.`, 'error');
      hideStatus();
    }
  };

  const downloadUgoira = async (illustData) => {
    updateStatus('Downloading Ugoira...');
    const ugoiraMetaUrl = `https://www.pixiv.net/ajax/illust/${illustData.id}/ugoira_meta`;
    const ugoiraData = await fetchWithCache(ugoiraMetaUrl);
    const zipUrl = ugoiraData.body.originalSrc;

    try {
      const zipBlob = await fetchWithCache(zipUrl, {
        responseType: "blob"
      });
      const zip = await JSZip.loadAsync(zipBlob);

      const frames = [];
      const frameDelays = ugoiraData.body.frames.map(frame => frame.delay);

      for (const [filename, file] of Object.entries(zip.files)) {
        if (!file.dir) {
          const blob = await file.async("blob");
          const imageBitmap = await createImageBitmap(blob);
          frames.push(imageBitmap);
        }
      }

      updateStatus('Converting Ugoira to GIF...');
      const gif = new GIF({
        workers: 2,
        quality: 10,
        width: frames[0].width,
        height: frames[0].height
      });

      frames.forEach((frame, index) => {
        gif.addFrame(frame, {
          delay: frameDelays[index]
        });
      });

      gif.on('finished', (blob) => {
        const gifUrl = URL.createObjectURL(blob);
        const filename = getFilename(illustData).replace(/\.\w+$/, '.gif');
        GM_download({
          url: gifUrl,
          name: filename,
          saveAs: true,
          onload: () => {
            URL.revokeObjectURL(gifUrl);
            showNotification('Ugoira download and conversion complete!', 'success');
            hideStatus();
          },
          onerror: (error) => {
            URL.revokeObjectURL(gifUrl);
            showNotification(`Ugoira download failed: ${error.message}`, 'error');
            hideStatus();
          }
        });
      });

      gif.render();
    }
    catch (error) {
      console.error('Error processing Ugoira:', error);
      showNotification(`Ugoira download failed: ${error.message}`, 'error');
      hideStatus();
    }
  };

  const copyArtworkInfo = async () => {
    try {
      const illustData = await getIllustData();
      const info = `Title: ${illustData.title}
Artist: ${illustData.userName}
ID: ${illustData.id}
Upload Date: ${new Date(illustData.uploadDate).toLocaleString()}
Tags: ${illustData.tags.tags.map(tag => tag.tag).join(', ')}
Description: ${illustData.description}`;

      GM_setClipboard(info);
      showNotification('Artwork info copied to clipboard!', 'success');
    }
    catch (error) {
      console.error('Error copying artwork info:', error);
      showNotification('Error copying artwork info', 'error');
    }
  };

  const downloadArtwork = async () => {
    try {
      updateStatus('Fetching artwork data...');
      const illustData = await getIllustData();
      if (!illustData) {
        throw new Error('Failed to fetch illustration data.');
      }

      if (illustData.illustType === 2) {
        await downloadUgoira(illustData);
      }
      else if (illustData.pageCount > 1) {
        await downloadMultiplePages(illustData);
      }
      else {
        await downloadSinglePage(illustData);
      }
    }
    catch (error) {
      updateStatus(`Error: ${error.message}`);
      console.error('Pixiv Downloader Error:', error);
      showNotification(`Error: ${error.message}`, 'error');
      hideStatus();
    }
  };

  const menuItems = [{
      name: 'Download Artwork',
      key: 'downloadArtwork',
      handler: downloadArtwork
    },
    {
      name: 'Copy Artwork Info',
      key: 'copyArtworkInfo',
      handler: copyArtworkInfo
    },
    {
      name: 'Use Original Filename',
      key: 'useOriginalFilename',
      type: 'toggle',
      handler: toggleUseOriginalFilename,
      getValue: () => GM_getValue('useOriginalFilename', false)
    },
    {
      name: 'Set Download Directory',
      key: 'downloadDirectory',
      prompt: 'Enter download directory:',
      handler: (value) => GM_setValue('downloadDirectory', value)
    },
    {
      name: 'Set Image Quality',
      key: 'imageQuality',
      prompt: 'Enter image quality (original/large/medium):',
      handler: (value) => GM_setValue('imageQuality', value)
    },
    {
      name: 'Auto Download',
      key: 'autoDownload',
      type: 'toggle',
      handler: toggleAutoDownload,
      getValue: () => GM_getValue('autoDownload', false)
    },
  ];

  function registerMenuCommands() {
    menuItems.forEach(item => {
      let commandText = item.name;
      if (item.type === 'toggle') {
        const value = item.getValue();
        commandText += `: ${value ? 'On' : 'Off'}`;
      }

      GM_registerMenuCommand(commandText, () => {
        if (item.type === 'toggle') {
          item.handler();
        }
        else if (item.prompt) {
          const value = prompt(item.prompt);
          if (value !== null) {
            item.handler(value);
          }
        }
        else {
          item.handler();
        }
        updateMenuCommands();
      });
    });
  }

  function updateMenuCommands() {

    menuItems.forEach(item => {
      GM_unregisterMenuCommand(item.name);
    });

    registerMenuCommands();
  }

  function toggleAutoDownload() {
    const currentValue = GM_getValue('autoDownload', false);
    GM_setValue('autoDownload', !currentValue);
    showNotification(`Auto Download: ${!currentValue ? 'On' : 'Off'}`);
  }

  function initializeScript() {
    createStatusElement();
    registerMenuCommands();

    if (GM_getValue('autoDownload', false)) {
      downloadArtwork();
    }
  }

  initializeScript();
})();