Pixiv Downloader

按画师名划分文件夹下载Pixiv原图,支持多图Zip/Gif。

Від 13.09.2021. Дивіться остання версія.

// ==UserScript==
// @name         Pixiv Downloader
// @namespace    https://greasyfork.org/zh-CN/scripts/432150
// @version      0.2.1
// @description  按画师名划分文件夹下载Pixiv原图,支持多图Zip/Gif。
// @author       ruaruarua
// @match        https://www.pixiv.net/*
// @icon         https://www.google.com/s2/favicons?domain=pixiv.net
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @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_FLAG = false; //需要保存GIF请设置为true
  const GIF_QUALITY = 5; //数值越小,GIF质量越好

  const workerStr = await fetch('https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js')
  .then(res=>res.blob());
  const gifConfig = {
    workers: 3,
    quality: GIF_QUALITY, 
    workerScript: URL.createObjectURL(workerStr)
  };

  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.authorFormatted = '';
      this.illustTitleFormatted = '';
      this.pageCount = 0;
      this.imgList = [];
      this.gifSrc = '';
      this.gifSrcInfo = {};
    }

    _toGIF(blob, self, path, resolve, reject) {
      let gifPromiseList = [];
      let 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._save(blob, path, resolve, reject);
              });
              gif.render();
            });
        })
    }

    _save(blob, path, resolve, reject) {
      const imgUrl = URL.createObjectURL(blob);
      GM_download({
        url: imgUrl,
        name: path,
        onerror: (error) => {
          console.log('Error: ',error);
          if (reject) {
            reject();
          }
        },
        onload: () => {
          console.log('Download complete: ' + path);
          URL.revokeObjectURL(imgUrl);
          if (resolve) {
            resolve();
          }
        }
      });
    }

    startDownload(path = '', dlSrc = '', onprogressCallback = null, resolve = null, reject = null) {
      let self = this;
      GM_xmlhttpRequest({
        url: dlSrc,
        method: 'GET',
        headers: {
          referer: 'https://www.pixiv.net'
        },
        responseType: 'blob',
        onprogress: (e) => {
          if (typeof onprogressCallback == 'function') {
            onprogressCallback(e);
          }
        },
        onload: function (res) {
          if (path.slice(-3) != 'gif') {
            self._save(res.response, path, resolve, reject);
          } else {
            if (typeof onprogressCallback == 'function') {
              onprogressCallback(res);
            }
            self._toGIF(res.response, self, path, resolve, reject);
          }
        },
        onerror: function () {
          console.log('XmlhttpRequest failed: ' + dlSrc);
          if (reject) {
            reject();
          }
        }
      });
    }

    _replaceInvalidChar(picInfo = '') {
      if (picInfo) {
        let newpicInfo = '';
        for (let i = 0; i < picInfo.length; i++) {
          const char = picInfo.charAt(i);
          switch (char) {
            case '\\':
            case '/':
            case ':':
            case '?':
            case '|':
              newpicInfo += '-';
              break;
            case '"':
              newpicInfo += "'";
              break;
            case '<':
              newpicInfo += "[";
              break;
            case '>':
              newpicInfo += "]";
              break;
            default:
              newpicInfo += char;
          }
        }
        return newpicInfo;
      }
    }

    async _initProps(htmlText) {
      this.data = JSON.parse(htmlText.match(/"meta-preload-data" content='(.*)'>/)[1]);
      if (this.data.illust) {
        this.author = this.data.illust[this.pixivID].userName;
        this.illustTitle = this.data.illust[this.pixivID].illustTitle;
        this.authorFormatted = this._replaceInvalidChar(this.author);
        this.illustTitleFormatted = this._replaceInvalidChar(this.illustTitle);
        this.illustType = this.data.illust[this.pixivID].illustType;

        if (this.illustType == 0 || this.illustType == 1) {
          this.pageCount = this.data.illust[this.pixivID].pageCount;
          let firstimgSrc = this.data.illust[this.pixivID].urls.original;
          let baseSrc = {
            srcPrefix: firstimgSrc.slice(0, firstimgSrc.indexOf('_') + 2),
            srcSuffix: firstimgSrc.slice(-4)
          };
          for (let i = 0; i < this.pageCount; i++) {
            const imgSrc = baseSrc.srcPrefix + i + baseSrc.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) {
      let basePath = this.authorFormatted + '/' + this.authorFormatted + '_' + this.illustTitleFormatted + '_' + this.pixivID;
      basePath = basePath.replace('./','/');

      if (this.illustType == 0 || this.illustType == 1) {
        let promiseList = [];
        if (this.imgList.length > 0) {
          this.imgList.forEach((imgSrc, i) => {
            promiseList[i] = new Promise((resolve, reject) => {
              const path = basePath + '_p' + i + imgSrc.slice(-4);
              this.startDownload(path, imgSrc, onprogressCallback, resolve, reject);
            });
          })
        }
        return Promise.all(promiseList);
      }

      if (this.illustType == 2) {
        return new Promise((resolve, reject) => {
          if (GIF_FLAG) {
            basePath += '.gif';
          } else {
            basePath += '.zip';
          }
          this.startDownload(basePath, this.gifSrcInfo.originalSrc, onprogressCallback, resolve, reject);
        });
      }
    }
  }

  function isLinkMatch(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;
  }

  function createDlBtn(isMain = false, pixivID = 0) {
    const dlBtn = document.createElement('a');
    dlBtn.setAttribute('href', 'javascript:void(0)');
    dlBtn.setAttribute('pixivID', pixivID);
    dlBtn.classList.add('dl-btn');
    if (!isMain) {
      dlBtn.classList.add('dl-sub-pic-btn');
      dlBtn.setAttribute('style', 'position:absolute; left:0; bottom:0; z-index:1; display:inline-block; width:32px; height:32px; margin:0; padding:0; cursor:pointer; background-color: rgba(255,255,255,0.5);');
    } else {
      dlBtn.classList.add('dl-pic-btn');
      dlBtn.setAttribute('style', 'display:inline-block; width:32px; height:32px; margin:0; padding:0; margin-left:10px; cursor:pointer; background-color: transparent;');
    }
    if (pixivStorage.has(pixivID)) {
      dlBtn.innerHTML = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 1024 1024\"><path fill=\"green\" d=\"M406.656 706.944 195.84 496.256a32 32 0 1 0-45.248 45.248l256 256 512-512a32 32 0 0 0-45.248-45.248L406.592 706.944z\"></path></svg>"
    } else {
      dlBtn.innerHTML = '<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 1024 1024\"><path d=\"M160 832h704a32 32 0 1 1 0 64H160a32 32 0 1 1 0-64zm384-253.696 236.288-236.352 45.248 45.248L508.8 704 192 387.2l45.248-45.248L480 584.704V128h64v450.304z\"></path></svg>'
    }
    return dlBtn;
  }

  function keyboardHandler(e) {
    // ctrl + d
    if (e.ctrlKey && e.key == 'd') {
      const dlBtn = rootNode.querySelector('.dl-pic-btn');
      if (dlBtn) {
        e.preventDefault();
        if (!e.repeat) {
          dlBtn.firstElementChild.dispatchEvent(new MouseEvent('click', { "bubbles": true }));
        }
      }
    }
  }

  function handleDownload(dlBtn = null, pixivID = '') {
    function onprogressCallback(e) {
      if (e.loaded && e.total != -1) {
        dlBtn.innerHTML = '<span style="line-height: 32px; font-size: 15px; color: green;">' + Math.round((e.loaded / e.total) * 100) + '</span>';
      } else {
        dlBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path fill="green" d="M224 160a64 64 0 0 0-64 64v576a64 64 0 0 0 64 64h576a64 64 0 0 0 64-64V224a64 64 0 0 0-64-64H224zm0-64h576a128 128 0 0 1 128 128v576a128 128 0 0 1-128 128H224A128 128 0 0 1 96 800V224A128 128 0 0 1 224 96z"></path><path fill="green" d="M384 416a64 64 0 1 0 0-128 64 64 0 0 0 0 128zm0 64a128 128 0 1 1 0-256 128 128 0 0 1 0 256z"></path><path fill="green" d="M480 320h256q32 0 32 32t-32 32H480q-32 0-32-32t32-32zm160 416a64 64 0 1 0 0-128 64 64 0 0 0 0 128zm0 64a128 128 0 1 1 0-256 128 128 0 0 1 0 256z"></path><path fill="green" d="M288 640h256q32 0 32 32t-32 32H288q-32 0-32-32t32-32z"></path></svg>';
      }
    }
    new PixivDownloader(pixivID)
      .fetchData()
      .then(downloader => downloader.download(onprogressCallback))
      .then(() => {
        dlBtn.innerHTML = "<svg xmlns=\"http://www.w3.org/2000/svg\" viewBox=\"0 0 1024 1024\"><path fill=\"green\" d=\"M406.656 706.944 195.84 496.256a32 32 0 1 0-45.248 45.248l256 256 512-512a32 32 0 0 0-45.248-45.248L406.592 706.944z\"></path></svg>";
        pixivStorage.add(pixivID);
        localStorage.pixivDownloader = JSON.stringify([...pixivStorage]);
      })
      .catch((err) => {
        dlBtn.innerHTML = '<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1024 1024"><path fill="orange" d="M512 64a448 448 0 1 1 0 896 448 448 0 0 1 0-896zm0 832a384 384 0 0 0 0-768 384 384 0 0 0 0 768zm48-176a48 48 0 1 1-96 0 48 48 0 0 1 96 0zm-48-464a32 32 0 0 1 32 32v288a32 32 0 0 1-64 0V288a32 32 0 0 1 32-32z"></path></svg>';
        if (err) {
          console.log(err);
        }
      });
  }

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

    if (isArtworksPage && !document.body.querySelector('.dl-pic-btn')) {
      const addFavousBtn = document.body.querySelector('.gtm-main-bookmark') || null;
      if (addFavousBtn) {
        const handleBar = addFavousBtn.parentElement.parentElement;
        const dlBtnWrap = addFavousBtn.parentElement.cloneNode();
        dlBtnWrap.appendChild(createDlBtn(true, isArtworksPage[1]));
        handleBar.appendChild(dlBtnWrap);
        document.addEventListener('keydown', keyboardHandler);
      }
    }

    const picLists = rootNode.querySelectorAll('a');
    picLists.forEach((link) => {
      const pixivID = isLinkMatch(link);
      if (pixivID && !link.querySelector('.dl-sub-pic-btn')) {
        link.appendChild(createDlBtn(false, pixivID));
      }
    })

  }

  localStorage.pixivDownloader = localStorage.pixivDownloader || JSON.stringify([]);
  let pixivStorage = new Set(JSON.parse(localStorage.pixivDownloader));
  const rootNode = document.body;
  const config = { attributes: false, childList: true, subtree: true };
  const rootObserver = new MutationObserver(observerCallback);
  rootObserver.observe(rootNode, config);

  rootNode.addEventListener('click', (e) => {
    if (/svg|path/i.test(e.target.tagName)) {
      let findDlBtn = e.target.closest('.dl-btn');
      if (findDlBtn) {
        const pixivID = findDlBtn.getAttribute('PixivID');
        e.stopPropagation();
        handleDownload(findDlBtn, pixivID);
      }
    }
  });

  let zip = new JSZip();

})();