Pixiv Downloader

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

目前為 2021-10-17 提交的版本,檢視 最新版本

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

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

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==UserScript==
// @name         Pixiv Downloader
// @namespace    https://greasyfork.org/zh-CN/scripts/432150
// @version      0.3.1
// @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.data = {};
      this.author = '';
      this.illustTitle = '';
      this.illustType = -1;
      this.pageCount = 1;
      this.pageDownloaded = 0;
      this.imgList = [];
      this.gifSrcInfo = {};
    }
    saveAsGif_(blob, self, savePath, resolve, reject) {
      const gifPromiseList = [];
      const imgList = [];
      zip.folder(`${self.pixivId}`)
        .loadAsync(blob)
        .then((zip) => {
          let gif = new GIF(gifConfig);
          let i = 0;
          zip.forEach((relativePath, file) => {
            gifPromiseList[i] = new Promise((loadResolve, loadReject) => {
              i += 1;
              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.gifSrcInfo.frames[i].delay });
              });
              gif.on('finished', (blob) => {
                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) => {
          console.log('Error: ', error);
          if (reject) {
            reject();
          }
        },
        onload: () => {
          URL.revokeObjectURL(imgUrl);
          if (resolve) {
            resolve();
          }
        }
      });
    }
    startDownload_(savePath, imgSrc, onProgressCallback, onLoadCallback, resolve, reject) {
      let self = this;
      GM_xmlhttpRequest({
        url: imgSrc,
        method: 'GET',
        headers: {
          referer: 'https://www.pixiv.net'
        },
        responseType: 'blob',
        onprogress: (e) => {
          if (typeof onProgressCallback == 'function') {
            onProgressCallback(self.pageCount, e);
          }
        },
        onload: function (res) {
          self.pageDownloaded++;
          if (typeof onLoadCallback == 'function') {
            onLoadCallback(self.pageDownloaded, self.pageCount);
          }
          if (savePath.slice(-3) != 'gif') {
            self.saveImg_(res.response, savePath, resolve, reject);
          } else {
            self.saveAsGif_(res.response, self, savePath, resolve, reject);
          }
        },
        onerror: function () {
          console.log('XmlhttpRequest failed: ' + imgSrc);
          if (reject) {
            reject();
          }
        }
      });
    }
    replaceInvalidChar_(oriString = '') {
      if (oriString) {
        let newString = '';
        for (let i = 0; i < oriString.length; i++) {
          const char = oriString.charAt(i);
          switch (char) {
            case '\\':
            case '/':
            case ':':
            case '?':
            case '|':
              newString += '-';
              break;
            case '"':
              newString += "'";
              break;
            case '<':
              newString += "[";
              break;
            case '>':
              newString += "]";
              break;
            case '\u200B':
              break;
            default:
              newString += char;
          }
        }
        return newString;
      }
    }
    async initProps_(htmlText) {
      this.data = JSON.parse(htmlText.match(/"meta-preload-data" content='(.*)'>/)[1]);
      if (this.data.illust) {
        const illustInfo = this.data.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);
          }
          return this;
        }
        if (this.illustType == 2) {
          const metaURL = `https://www.pixiv.net/ajax/illust/${this.pixivId}/ugoira_meta?lang=zh`;
          this.gifSrcInfo = await fetch(metaURL)
            .then(res => res.json())
            .then(res => res.body)
            .catch(() => '');
          if (/img-zip-ugoira/.test(this.gifSrcInfo.originalSrc)) {
            return this;
          } else {
            throw new Error('Fail to parse gif src.');
          }
        }
      }
      throw new Error('Fail to parse html.');
    }
    fetchData() {
      return fetch(this.artworksURL)
        .then(res => res.text())
        .then(htmlText => this.initProps_(htmlText))
    }
    download(onProgressCallback, onLoadCallback) {
      let baseSavePath = this.author + '/' + this.author + '_' + this.illustTitle + '_' + this.pixivId;
      baseSavePath = baseSavePath.replace('./', '/');
      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.startDownload_(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.startDownload_(baseSavePath, this.gifSrcInfo.originalSrc, onProgressCallback, onLoadCallback, resolve, reject);
        });
      }
    }
  }
  const getPixivId = function (link = null) {
    const artworkReg = /artworks\/([0-9]+)$/;
    const isHrefMatch = artworkReg.exec(link.href);
    if (isHrefMatch) {
      if (link.getAttribute('data-gtm-value') || link.classList.contains('gtm-illust-recommend-thumbnail-link') || link.classList.contains('gtm-discover-user-recommend-thumbnail') || link.classList.contains('work')) {
        return isHrefMatch[1];
      }
      if (window.location.href.indexOf('bookmark_new_illust') != -1 && link.getAttribute('class')) {
        return isHrefMatch[1];
      }
    } else {
      const activityReg = /illust_id=([0-9]+)/;
      const isActivityMatch = activityReg.exec(link.href);
      if (isActivityMatch && link.classList.contains('work')) {
        return isActivityMatch[1];
      }
    }
    return false;
  }
  const createPdlBtn = function (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 = function (pdlBtn, pixivId) {
    const onProgressCallback = function (pageCount = 1, e) {
      if (pageCount == 1) {
        if (e.loaded && e.total != -1) {
          pdlBtn.innerHTML = Math.round((e.loaded / e.total) * 100);
        } else {
          pdlBtn.innerHTML = '';
        }
      }
    };
    const onLoadCallback = function (pageDownloaded = 0, pageCount = 1) {
      if (pageCount != 1) {
        pdlBtn.innerHTML = Math.round((pageDownloaded / pageCount) * 100);
      } else {
        pdlBtn.innerHTML = '';
      }
    }
    pdlBtn.classList.add('pdl-progress');
    new PixivDownloader(pixivId)
      .fetchData()
      .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.classList.remove('pdl-progress');
      });
  }
  const shortKeyHandler = function (e) {
    if (e.ctrlKey && e.key == 'q') {
      const pdlMainBtn = this.querySelector('.pdl-btn-main');
      if (pdlMainBtn) {
        e.preventDefault();
        if (!e.repeat) {
          pdlMainBtn.dispatchEvent(new MouseEvent('click', { "bubbles": true }));
        }
      }
    }
  }
  const observerCallback = function () {
    const reg = /artworks\/([0-9]+)$/;
    const isArtworksPage = reg.exec(window.location.href);
    if (isArtworksPage && !bodyNode.querySelector('.pdl-btn-main')) {
      const addFavousBtn = bodyNode.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);
        bodyNode.addEventListener('keydown', shortKeyHandler);
      }
    }
    const picLists = bodyNode.querySelectorAll('a');
    picLists.forEach((e) => {
      if (!e.querySelector('.pdl-btn-sub')) {
        const pixivId = getPixivId(e);
        if (pixivId) {
          e.appendChild(createPdlBtn(false, pixivId));
        }
      }
    });
  }
  localStorage.isPdlGif = localStorage.isPdlGif || 'false';
  localStorage.pixivDownloader = localStorage.pixivDownloader || '[]';
  let GIF_FLAG = JSON.parse(localStorage.isPdlGif);
  let pixivStorage = new Set(JSON.parse(localStorage.pixivDownloader));
  const updateStorage = function(){
    Object.keys(localStorage).forEach((string) => {
      const matchResult = /pdlTemp-(\d+)/.exec(string);
      if (matchResult) {
        pixivStorage.add(matchResult[1]);
        localStorage.removeItem(matchResult[0]);
      }
    });
  };
  updateStorage();
  localStorage.pixivDownloader = JSON.stringify([...pixivStorage]);
  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', () => { updateStorage(); localStorage.pixivDownloader = '[]'; }, 'c');
  const pdlStyle = document.createElement('style');
  pdlStyle.innerHTML = `    .pdl-btn {
    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: 16px;
    font-weight: bold;
    height: 32px;
    line-height: 32px;
    margin: 0;
    overflow: hidden;
    padding: 0;
    text-decoration: none!important;
    text-align: center;
    text-overflow: ellipsis;
    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:empty {
    background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 384 512'%3E %3Cpath fill='%2301B468' d='M368 48h4c6.627 0 12-5.373 12-12V12c0-6.627-5.373-12-12-12H12C5.373 0 0 5.373 0 12v24c0 6.627 5.373 12 12 12h4c0 80.564 32.188 165.807 97.18 208C47.899 298.381 16 383.9 16 464h-4c-6.627 0-12 5.373-12 12v24c0 6.627 5.373 12 12 12h360c6.627 0 12-5.373 12-12v-24c0-6.627-5.373-12-12-12h-4c0-80.564-32.188-165.807-97.18-208C336.102 213.619 368 128.1 368 48zM64 48h256c0 101.62-57.307 184-128 184S64 149.621 64 48zm256 416H64c0-101.62 57.308-184 128-184s128 82.38 128 184z'%3E%3C/path%3E %3C/svg%3E");
    background-size: 60%;
  }`;
  document.head.appendChild(pdlStyle);
  const bodyNode = document.body;
  const config = { attributes: false, childList: true, subtree: true };
  const rootObserver = new MutationObserver(observerCallback);
  rootObserver.observe(bodyNode, config);
  let 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)
  };
  bodyNode.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);
      }
    }
  });
})();