Pixiv Downloader

一键(快捷键)下载Pixiv各页面的原图,支持多图下载,动图下载支持Zip压缩包/Gif动图格式切换。下载的图片将保存到以画师名命名的单独文件夹(需要调整tampermonkey“下载”设置为“浏览器API”)。保留已下载图片的记录。

Mint 2022.01.20.. Lásd a legutóbbi verzió

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         Pixiv Downloader
// @namespace    https://greasyfork.org/zh-CN/scripts/432150
// @version      0.3.5
// @description  一键(快捷键)下载Pixiv各页面的原图,支持多图下载,动图下载支持Zip压缩包/Gif动图格式切换。下载的图片将保存到以画师名命名的单独文件夹(需要调整tampermonkey“下载”设置为“浏览器API”)。保留已下载图片的记录。
// @author       ruaruarua
// @match        https://www.pixiv.net/*
// @icon         https://www.google.com/s2/favicons?domain=pixiv.net
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @grant        GM_registerMenuCommand
// @connect      i.pximg.net
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.7.1/jszip.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js
// ==/UserScript==

(async function () {

  const GIF_QUALITY = 10; //0-10。数值越小,GIF质量越高,转换时间越长

  class PixivDownloader {
    constructor(pixivId) {
      this.pixivId = pixivId;
      this.artworksURL = 'https://www.pixiv.net/artworks/' + pixivId;
      this.gifOriginalSrc = 'https://www.pixiv.net/ajax/illust/' + pixivId + '/ugoira_meta';
      this.author = '';
      this.illustTitle = '';
      this.illustType = -1;  
      this.pageCount = 1;
      this.pageDownloaded = 0;
      this.imgList = [];
      this.ugoiraMeta = {};
    }

    saveAsGif_(blob, self, savePath, resolve, reject) {
      const gifPromiseList = [];
      const imgList = [];

      zip.folder(`${self.pixivId}`)
        .loadAsync(blob)
        .then((zip) => {
          const gif = new GIF(gifConfig);
          let i = 0;

          zip.forEach((relativePath, file) => {
            gifPromiseList[i] = new Promise((loadResolve) => {
              i++;
              const image = new Image();
              imgList.push(image);
              image.onload = () => {
                loadResolve();
              };
              file.async('blob')
                .then((blob) => {
                  const objectURL = URL.createObjectURL(blob);
                  image.src = objectURL;
                });
            });
          });

          Promise.all(gifPromiseList)
            .then(() => {
              zip.remove(`${self.pixivId}`);
              imgList.forEach((e, i) => {
                gif.addFrame(e, { delay: self.ugoiraMeta.frames[i].delay });
              });
              gif.on('finished', (blob) => void self.saveImg_(blob, savePath, resolve, reject));
              gif.render();
            });
        });
    }

    saveImg_(blob, savePath, resolve, reject) {
      const imgUrl = URL.createObjectURL(blob);
      GM_download({
        url: imgUrl,
        name: savePath,
        onerror: (error) => {
          if (reject) {
            console.log('save error.')
            reject(error);
          }
        },
        onload: () => {
          URL.revokeObjectURL(imgUrl);
          if (resolve) {
            resolve();
          }
        },
      });
    }

    fetchIllust_(savePath, imgSrc, onProgressCallback = null, onLoadCallback = null, resolve, reject) {
      const self = this;
      GM_xmlhttpRequest({
        url: imgSrc,
        method: 'GET',
        headers: {
          referer: 'https://www.pixiv.net'
        },
        responseType: 'blob',
        onprogress: (e) => {
          if(e.lengthComputable && e.loaded == e.total){
            self.pageDownloaded++;
          }

          if (typeof onProgressCallback == 'function') {
            onProgressCallback(e, self.pageDownloaded, self.pageCount);
          }
        },
        onload: (e) => {
          if (typeof onLoadCallback == 'function') {
            onLoadCallback(e, self.pageDownloaded, self.pageCount);
          }

          if (savePath.slice(-3) != 'gif') {
            self.saveImg_(e.response, savePath, resolve, reject);
          } else {
            self.saveAsGif_(e.response, self, savePath, resolve, reject);
          }
        },
        onerror: (error) => {
          if (reject) {
            console.log('xmlhttpRequest failed.')
            reject(error);
          }
        },
      });
    }

    replaceInvalidChar_(string = '') {
      return string.trim()
        .replace(/^\.|\.$/g, '')    
        .replace(/[\u200b-\u200f\uFEFF\u202a-\u202e\\/:*?"|]/g, "")
        .replace(/"/g, "'")
        .replace(/</g, '[')
        .replace(/>/g, ']');
    }

    initProps_(preloadData, ugoiraMeta) {
      const illustInfo = preloadData.illust[this.pixivId];
      this.author = this.replaceInvalidChar_(illustInfo.userName) || 'userId-' + illustInfo.userId;
      this.illustTitle = this.replaceInvalidChar_(illustInfo.illustTitle) || 'illustId-' + illustInfo.illustId;
      this.illustType = illustInfo.illustType;

      if (this.illustType == 0 || this.illustType == 1) {
        this.pageCount = illustInfo.pageCount;
        const firstImgSrc = illustInfo.urls.original;
        const srcPrefix = firstImgSrc.slice(0, firstImgSrc.indexOf('_') + 2);
        const srcSuffix = firstImgSrc.slice(-4);

        for (let i = 0; i < this.pageCount; i++) {
          const imgSrc = srcPrefix + i + srcSuffix;
          this.imgList.push(imgSrc);
        }
      }

      if (this.illustType == 2) {
        this.ugoiraMeta = ugoiraMeta;
      }
    }

    initial() {
      return fetch(this.artworksURL)
        .then((res) => {
          if (!res.ok) throw new Error('fetch artworksURL failed: ' + res.status);

          return res.text();
        })
        .then(async (htmlText) => {
          const preloadData = JSON.parse(htmlText.match(/"meta-preload-data" content='(.*)'>/)[1]);
          let ugoiraMeta = {};

          if (!preloadData.illust) throw new Error('Fail to parse meta preload data.');

          if (preloadData.illust[this.pixivId].illustType == 2) {
            ugoiraMeta = await fetch(this.gifOriginalSrc)
              .then((res) => {
                if (!res.ok) throw new Error('fetch ugoira meta failed: ' + res.status);
                return res.json();
              })
              .then((res) => res.body);

            if (!/img-zip-ugoira/.test(ugoiraMeta.originalSrc)) throw new Error('Fail to parse originalSrc.');
          }

          this.initProps_(preloadData, ugoiraMeta);
          return this;
        });
    }

    download(onProgressCallback, onLoadCallback) {
      let baseSavePath = this.author + '/' + this.author + '_' + this.illustTitle + '_' + this.pixivId;

      if (this.illustType == 0 || this.illustType == 1) {
        const promiseList = [];

        if (this.imgList.length > 0) {
          this.imgList.forEach((imgSrc, i) => {
            promiseList[i] = new Promise((resolve, reject) => {
              const savePath = baseSavePath + '_p' + i + imgSrc.slice(-4);
              this.fetchIllust_(savePath, imgSrc, onProgressCallback, onLoadCallback, resolve, reject);
            });
          })
        }

        return Promise.all(promiseList);
      }

      if (this.illustType == 2) {
        return new Promise((resolve, reject) => {
          if (GIF_FLAG) {
            baseSavePath += '.gif';
          } else {
            baseSavePath += '.zip';
          }
          this.fetchIllust_(baseSavePath, this.ugoiraMeta.originalSrc, onProgressCallback, onLoadCallback, resolve, reject);
        });
      }
    }
  }

  const getPixivId = (thumbnail = null) => {
    if (thumbnail.childElementCount === 0) return false;
    
    const artworkReg = /artworks\/([0-9]+)$/;
    const isHrefMatch = artworkReg.exec(thumbnail.href);

    if (isHrefMatch) {
      if (thumbnail.getAttribute('data-gtm-value') || thumbnail.classList.contains('gtm-illust-recommend-thumbnail-thumbnail') || thumbnail.classList.contains('gtm-discover-user-recommend-thumbnail') || thumbnail.classList.contains('work')) {
        return isHrefMatch[1];
      }

      if (window.location.href.indexOf('bookmark_new_illust') != -1 && thumbnail.getAttribute('class')) {
        return isHrefMatch[1];
      }
    } else {
      const activityReg = /illust_id=([0-9]+)/;
      const isActivityMatch = activityReg.exec(thumbnail.href);

      if (isActivityMatch && thumbnail.classList.contains('work')) {
        return isActivityMatch[1];
      }
    }

    return false;
  }

  const createPdlBtn = (isMain = false, pixivId = '') => {
    const pdlBtn = document.createElement('a');
    pdlBtn.setAttribute('href', 'javascript:void(0)');
    pdlBtn.setAttribute('pdl-id', pixivId);
    pdlBtn.classList.add('pdl-btn');

    if (!isMain) {
      pdlBtn.classList.add('pdl-btn-sub');
    } else {
      pdlBtn.classList.add('pdl-btn-main');
    }
    if (pixivStorage.has(pixivId)) {
      pdlBtn.classList.add('pdl-complete');
    }

    return pdlBtn;
  }

  const handleDownload = (pdlBtn, pixivId) => {
    const onProgressCallback = (e, pageDownloaded = 0, pageCount = 1) => {
      if (e.lengthComputable) {
        let progress = 0;

        if (pageCount == 1) {
          progress = Math.floor((e.loaded / e.total) * 100);
        } 
        if (pageCount > 1) {
          progress = Math.floor((pageDownloaded / pageCount) * 100);
        }

        pdlBtn.textContent = progress;
        pdlBtn.style.setProperty('--pdl-progress', progress + '%');
      }
    };

    const onLoadCallback = (e) => {
      if (GIF_FLAG && 'zip' == e.finalUrl.slice(-3)) {
        pdlBtn.innerHTML = '';
      }
    }

    pdlBtn.classList.add('pdl-progress');
    new PixivDownloader(pixivId)
      .initial()
      .then((downloader) => downloader.download(onProgressCallback, onLoadCallback))
      .then(() => {
        pixivStorage.add(pixivId);
        localStorage.setItem(`pdlTemp-${pixivId}`, '')
        pdlBtn.classList.remove('pdl-error');
        pdlBtn.classList.add('pdl-complete');
      })
      .catch((err) => {
        if (err) console.log(err);
        pdlBtn.classList.remove('pdl-complete');
        pdlBtn.classList.add('pdl-error');
      })
      .finally(() => {
        pdlBtn.innerHTML = '';
        pdlBtn.style.removeProperty('--pdl-progress');
        pdlBtn.classList.remove('pdl-progress');
      });
  }

  const observerCallback = () => {
    const reg = /artworks\/([0-9]+)$/;
    const isArtworksPage = reg.exec(window.location.href);

    if (isArtworksPage && !body.querySelector('.pdl-btn-main')) {
      const addFavousBtn = body.querySelector('.gtm-main-bookmark');
      if (addFavousBtn) {
        const handleBar = addFavousBtn.parentElement.parentElement;
        const pdlBtnWrap = addFavousBtn.parentElement.cloneNode();
        pdlBtnWrap.appendChild(createPdlBtn(true, isArtworksPage[1]));
        handleBar.appendChild(pdlBtnWrap);
        body.addEventListener('keydown', (e) => {
          if (e.ctrlKey && e.key == 'q') {
            const pdlMainBtn = body.querySelector('.pdl-btn-main');
            if (pdlMainBtn) {
              e.preventDefault();
              if (!e.repeat) {
                pdlMainBtn.dispatchEvent(new MouseEvent('click', { "bubbles": true }));
              }
            }
          }
        });
      }
    }

    const picLists = body.querySelectorAll('a');
    picLists.forEach((e) => {
      if (!e.querySelector('.pdl-btn-sub')) {
        const pixivId = getPixivId(e);
        if (pixivId) {
          e.appendChild(createPdlBtn(false, pixivId));
        }
      }
    });
  }

  const updateHistory = () => {
    Object.keys(localStorage).forEach((key) => {
      const matchResult = /pdlTemp-(\d+)/.exec(key);
      if (matchResult) {
        pixivStorage.add(matchResult[1]);
        localStorage.removeItem(matchResult[0]);
      }
    });
    localStorage.pixivDownloader = JSON.stringify([...pixivStorage]);
  }

  localStorage.isPdlGif = localStorage.isPdlGif || 'false';
  localStorage.pixivDownloader = localStorage.pixivDownloader || '[]';
  let GIF_FLAG = JSON.parse(localStorage.isPdlGif);

  GM_registerMenuCommand('Gif', () => { GIF_FLAG = true; localStorage.isPdlGif = GIF_FLAG; }, 'g');
  GM_registerMenuCommand('Zip', () => { GIF_FLAG = false; localStorage.isPdlGif = GIF_FLAG; }, 'z');
  GM_registerMenuCommand('Clear history', () => { updateHistory(); localStorage.pixivDownloader = '[]'; }, 'c');

  let pixivStorage = new Set(JSON.parse(localStorage.pixivDownloader));
  updateHistory();

  const pdlStyle = document.createElement('style');
  pdlStyle.innerHTML = `
  @property --pdl-progress {
    syntax: '<percentage>';
    inherits: true;
    initial-value: 0%;
  }
  @keyframes pdl_loading {
    100% {
      transform: translate(-50%, -50%) rotate(360deg);
    }
  }
  .pdl-btn {
    position: relative;
    border-top-right-radius: 8px;
    background: no-repeat center/85%;
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E %3Cpath fill='%233C3C3C' d='M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200zm-32-316v116h-67c-10.7 0-16 12.9-8.5 20.5l99 99c4.7 4.7 12.3 4.7 17 0l99-99c7.6-7.6 2.2-20.5-8.5-20.5h-67V140c0-6.6-5.4-12-12-12h-40c-6.6 0-12 5.4-12 12z'%3E%3C/path%3E %3C/svg%3E");
    color: #01b468;
    display: inline-block;
    font-size: 13px;
    font-weight: bold;
    height: 32px;
    line-height: 32px;
    margin: 0;
    overflow: hidden;
    padding: 0;
    text-decoration: none!important;
    text-align: center;
    text-overflow: ellipsis;
    user-select: none;
    white-space: nowrap;
    width: 32px;
    z-index: 1;
  }
  .pdl-btn-main {
    margin: 0 0 0 10px;
  }
  .pdl-btn-sub {
    bottom: 0;
    background-color: rgba(255, 255, 255, .5);
    left: 0;
    position: absolute;
  }
  .pdl-error {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E %3Cpath fill='%23EA0000' d='M256 8C119 8 8 119 8 256s111 248 248 248 248-111 248-248S393 8 256 8zm0 448c-110.5 0-200-89.5-200-200S145.5 56 256 56s200 89.5 200 200-89.5 200-200 200zm101.8-262.2L295.6 256l62.2 62.2c4.7 4.7 4.7 12.3 0 17l-22.6 22.6c-4.7 4.7-12.3 4.7-17 0L256 295.6l-62.2 62.2c-4.7 4.7-12.3 4.7-17 0l-22.6-22.6c-4.7-4.7-4.7-12.3 0-17l62.2-62.2-62.2-62.2c-4.7-4.7-4.7-12.3 0-17l22.6-22.6c4.7-4.7 12.3-4.7 17 0l62.2 62.2 62.2-62.2c4.7-4.7 12.3-4.7 17 0l22.6 22.6c4.7 4.7 4.7 12.3 0 17z'%3E%3C/path%3E %3C/svg%3E");
  }
  .pdl-complete {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E %3Cpath fill='%2301B468' d='M256 8C119.033 8 8 119.033 8 256s111.033 248 248 248 248-111.033 248-248S392.967 8 256 8zm0 48c110.532 0 200 89.451 200 200 0 110.532-89.451 200-200 200-110.532 0-200-89.451-200-200 0-110.532 89.451-200 200-200m140.204 130.267l-22.536-22.718c-4.667-4.705-12.265-4.736-16.97-.068L215.346 303.697l-59.792-60.277c-4.667-4.705-12.265-4.736-16.97-.069l-22.719 22.536c-4.705 4.667-4.736 12.265-.068 16.971l90.781 91.516c4.667 4.705 12.265 4.736 16.97.068l172.589-171.204c4.704-4.668 4.734-12.266.067-16.971z'%3E%3C/path%3E %3C/svg%3E");
  }
  .pdl-progress {
    background-image: none;
    cursor: default;
  }
  .pdl-progress:after{
    content: '';
    display: inline-block;
    position: absolute;
    top: 50%;
    left: 50%;
    width: 27px;
    height: 27px;
    transform: translate(-50%, -50%);
    -webkit-mask: radial-gradient(transparent, transparent 54%, #000 57%, #000);
    mask: radial-gradient(transparent, transparent 54%, #000 57%, #000);
    border-radius: 50%;
  }
  .pdl-progress:not(:empty):after {
    background: conic-gradient(#01B468 0, #01B468 var(--pdl-progress), transparent var(--pdl-progress), transparent);
    transition: --pdl-progress .2s ease;
  }
  .pdl-progress:empty:after {
    background: conic-gradient(#01B468 0, #01B468 25%, #01B46833 25%, #01B46833);
    animation: 1.5s infinite linear pdl_loading;
  }`;
  document.head.appendChild(pdlStyle);

  const body = document.body;
  const config = {
    attributes: false,
    childList: true,
    subtree: true,
  };
  const rootObserver = new MutationObserver(observerCallback);
  rootObserver.observe(body, config);

  const zip = new JSZip();
  const workerStr = await fetch('https://raw.githubusercontent.com/jnordberg/gif.js/master/dist/gif.worker.js').then((res) => res.blob());
  const gifConfig = {
    workers: 3,
    quality: GIF_QUALITY,
    workerScript: URL.createObjectURL(workerStr),
  };

  body.addEventListener('click', (e) => {
    if (e.target.hasAttribute('pdl-id')) {
      e.stopPropagation();
      if (!e.target.classList.contains('pdl-progress')) {
        const pixivId = e.target.getAttribute('pdl-id');
        handleDownload(e.target, pixivId);
      }
    }
  });
})();