Pixiv Downloader

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

Pada tanggal 28 November 2021. Lihat %(latest_version_link).

// ==UserScript==
// @name         Pixiv Downloader
// @namespace    https://greasyfork.org/zh-CN/scripts/432150
// @version      0.3.4
// @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) => {
    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);
      }
    }
  });
})();