Pixiv Downloader

一键下载Pixiv各页面原图。支持多图下载,动图下载,画师作品批量下载。动图支持格式转换:Gif | Apng | Webm。下载的图片将保存到以画师名命名的单独文件夹(需要调整tampermonkey“下载”设置为“浏览器API”)。保留已下载图片的记录。

À partir de 2022-10-09. Voir la dernière version.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==UserScript==
// @name         Pixiv Downloader
// @namespace    https://greasyfork.org/zh-CN/scripts/432150
// @version      0.5.2
// @description:en  Download the original images of Pixiv pages with one click. Supports:multiple illustrations, ugoira(animation), and batch downloads of artists' work. Ugoira support format conversion: Gif | Apng | Webm. The downloaded images will be saved in a separate folder named after the artist (you need to adjust the tampermonkey "Download" setting to "Browser API"). A record of downloaded images is kept.
// @description  一键下载Pixiv各页面原图。支持多图下载,动图下载,画师作品批量下载。动图支持格式转换:Gif | Apng | Webm。下载的图片将保存到以画师名命名的单独文件夹(需要调整tampermonkey“下载”设置为“浏览器API”)。保留已下载图片的记录。
// @description:zh-TW  一鍵下載Pixiv各頁面原圖。支持多圖下載,動圖下載,畫師作品批次下載。動圖支持格式轉換:Gif | Apng | Webm。下載的圖片將保存到以畫師名命名的單獨文件夾(需要調整tampermonkey“下載”設置為“瀏覽器API”)。保留已下載圖片的紀錄。
// @author       ruaruarua
// @match        https://www.pixiv.net/*
// @icon         https://www.pixiv.net/favicon.ico
// @grant        GM_xmlhttpRequest
// @grant        GM_download
// @grant        GM_setValue
// @grant        GM_getValue
// @grant        GM_info
// @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==
(function () {
  'use strict';

  const style = `
@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;
  border: none;
  text-decoration: none!important;
  text-align: center;
  text-overflow: ellipsis;
  user-select: none;
  white-space: nowrap;
  width: 32px;
  z-index: 1;
  cursor: pointer;
}
.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-btn-sub.artworks{
  position: sticky;
  top: 40px;
  border-radius: 4px;
}
.pdl-btn-sub.presentation{
  position: fixed;
  top: 50px;
  right: 16px;
  border-radius: 8px;
  left: auto;
}
.pdl-btn-sub-bookmark.pdl-btn-sub-bookmark {
  left: auto;
  right: 0;
  bottom: 34px;
  border-radius: 8px;
  border-top-right-radius: 0px;
  border-bottom-right-radius: 0px;
}
.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;
}
.pdl-nav-placeholder {
  flex-grow: 1;
  height: 42px;
  line-height: 42px;
  text-align: right;
  font-weight: bold;
  font-size: 16px;
  color: rgb(133, 133, 133);
  border-top: 4px solid transparent;
  cursor: default;
  white-space: nowrap;
}
.pdl-btn-all,
.pdl-stop {
  background-color: transparent;
  border: none;
}
.pdl-btn-all::before,
.pdl-stop::before {
  content: '';
  height: 24px;
  width: 24px;
  transition: background-image 0.2s ease 0s;
  background: no-repeat center/85%;
}
.pdl-btn-all::before {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E %3Cpath fill='%23858585' 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");
}
.pdl-stop::before {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E %3Cpath fill='%23858585' 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-btn-all:hover::before{
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E %3Cpath fill='%231F1F1F' 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");
}
.pdl-stop:hover::before {
  background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 512 512'%3E %3Cpath fill='%231F1F1F' 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-hide {
  display: none!important;
}
.pdl-wrap {
  text-align: right;
  padding-right: 24px;
  font-weight: bold;
  font-size: 14px;
  line-height: 14px;
  color: rgb(133, 133, 133);
  transition: color 0.2s ease 0s;
}
.pdl-wrap:hover {
  color: rgb(31, 31, 31);
}
.pdl-wrap label {
  padding-left: 8px;
  cursor: pointer;
}
.pdl-wrap input {
  vertical-align: top;
  appearance: none;
  position: relative;
  box-sizing: border-box;
  width: 28px;
  border: 2px solid transparent;
  cursor: pointer;
  border-radius: 14px;
  height: 14px;
  background-color: rgba(133, 133, 133);
  transition: background-color 0.2s ease 0s, box-shadow 0.2s ease 0s;
}
.pdl-wrap input:hover {
  background-color: rgba(31, 31, 31);
}
.pdl-wrap input::after {
  content: "";
  position: absolute;
  display: block;
  top: 0px;
  left: 0px;
  width: 10px;
  height: 10px;
  transform: translateX(0px);
  background-color: rgb(255, 255, 255);
  border-radius: 10px;
  transition: transform 0.2s ease 0s;
}
.pdl-wrap input:checked {
  background-color: rgb(0, 150, 250);
}
.pdl-wrap input:checked::after {
  transform: translateX(14px);
}
.pdl-wrap-artworks {
  position: absolute;
  right: 8px;
  top: 0px;
  bottom: 0px;
  margin-top: 40px;
}`;
  function addStyle() {
    const sty = document.createElement("style");
    sty.innerHTML = style;
    document.head.appendChild(sty);
  }

  function debugLog(...msgs) {
  }

  let convertFormat =
    localStorage.pdlFormat || (localStorage.pdlFormat = "zip");
  const DEFAULT_FILENAME = "{artist}_{title}_{id}_p{page}";
  let filenamePattern =
    localStorage.pdlFilename || (localStorage.pdlFilename = DEFAULT_FILENAME);
  localStorage.pixivDownloader = localStorage.pixivDownloader || "[]";
  let pixivStorage = new Set(JSON.parse(localStorage.pixivDownloader));
  function 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]);
  }
  function clearHistory() {
    const isConfirm = confirm("Do you really want to clear history?");
    if (!isConfirm) return;
    updateHistory();
    pixivStorage = new Set();
    localStorage.pixivDownloader = "[]";
  }
  function createSetFormatFn(format) {
    return () => {
      convertFormat = localStorage.pdlFormat = format;
    };
  }
  function editFilename() {
    const newPattern = prompt(
      `Default: ${DEFAULT_FILENAME}\nYou can use: {artist}, {artistID}, {title}, {id}, {page}`,
      filenamePattern
    );
    if (!newPattern) return;
    localStorage.pdlFilename = filenamePattern = newPattern
      .trim()
      .replace(/^\.|[\u200b-\u200f\uFEFF\u202a-\u202e\\/:*?"|<>]/g, "");
  }

  async function getGifWS() {
    const Url =
      "https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.worker.js";
    let gifWS;
    if (!(gifWS = await GM_getValue("gifWS"))) {
      gifWS = await fetch(Url)
        .then((res) => res.blob())
        .then((blob) => blob.text());
      GM_setValue("gifWS", gifWS);
    }
    return gifWS;
  }
  async function getApngWS() {
    const pakoUrl =
      "https://cdnjs.cloudflare.com/ajax/libs/pako/2.0.4/pako.min.js";
    const upngUrl =
      "https://cdnjs.cloudflare.com/ajax/libs/upng-js/2.1.0/UPNG.min.js";
    let apngWS;
    if (!(apngWS = await GM_getValue("apngWS"))) {
      const pako = await fetch(pakoUrl).then((res) => res.text());
      const upng = await fetch(upngUrl)
        .then((res) => res.text())
        .then((js) =>
          js.replace("window.UPNG", "UPNG").replace("window.pako", "pako")
        );
      const workerEvt = `onmessage = (evt) => {
      const {data, width, height, delay } = evt.data;
      const png = UPNG.encode(data, width, height, 0, delay, {loop: 0});
      if (!png) console.log('Convert Apng failed.');
      postMessage(png);
    };`;
      apngWS = workerEvt + pako + upng;
      GM_setValue("apngWS", apngWS);
    }
    return apngWS;
  }
  function createConverter({ gifWS, apngWS }) {
    const zip = new JSZip();
    const gifConfig = {
      workers: 2,
      quality: 10,
      workerScript: gifWS,
    };
    const freeApngWorkers = [];
    const apngWorkers = [];
    const MAX_CONVERT = 2;
    let queue = [];
    let active = [];
    let isStop = false;
    const convert = (convertMeta) => {
      const { blob, meta, callBack } = convertMeta;
      meta.abort = () => void (meta.state = 0);
      active.push(convertMeta);
      meta.onProgress && meta.onProgress(0, "zip");
      zip
        .folder(meta.id)
        .loadAsync(blob)
        .then((zip) => {
          const promises = [];
          zip.forEach((relativePath, file) => {
            promises.push(
              new Promise((resolve) => {
                const image = new Image();
                image.onload = () => {
                  resolve(image);
                };
                file
                  .async("blob")
                  .then((blob) => void (image.src = URL.createObjectURL(blob)));
              })
            );
          });
          return Promise.all(promises);
        })
        .then((imgList) => {
          zip.remove(meta.id);
          if (!meta.state)
            throw "Convert stop manually, reject when unzip. " + meta.id;
          if (convertFormat === "zip") throw "no need to convert";
          return convert2[convertFormat](imgList, meta, callBack);
        })
        .catch((err) => {
          meta.reject("Error when converting. " + err);
        })
        .finally(() => {
          active.splice(active.indexOf(convertMeta), 1);
          if (queue.length) convert(queue.shift());
        });
    };
    const toGif = (frames, meta, callBack) => {
      return new Promise((resolve, reject) => {
        let gif = new GIF(gifConfig);
        meta.abort = () => {
          meta.state = 0;
          gif.abort();
          reject("Convert stop manually, reject when convert gif. " + meta.id);
        };
        debugLog("Convert:", meta.path);
        frames.forEach((frame, i) => {
          gif.addFrame(frame, { delay: meta.ugoiraMeta.frames[i].delay });
        });
        gif.on(
          "progress",
          (() => {
            const type = "gif";
            return (progress) => {
              debugLog("Convert progress:", meta.path);
              meta.onProgress && meta.onProgress(progress, type);
            };
          })()
        );
        gif.on("finished", (gifBlob) => {
          if (typeof callBack === "function") callBack(gifBlob, meta);
          frames.forEach((frame) => {
            URL.revokeObjectURL(frame.src);
          });
          gif = null;
          resolve();
        });
        gif.on("abort", () => {
          gif = null;
        });
        gif.render();
      });
    };
    const toApng = (frames, meta, callBack) => {
      return new Promise((resolve, reject) => {
        let canvas = document.createElement("canvas");
        const width = (canvas.width = frames[0].naturalWidth);
        const height = (canvas.height = frames[0].naturalHeight);
        const context = canvas.getContext("2d");
        const data = [];
        const delay = meta.ugoiraMeta.frames.map((frame) => {
          return Number(frame.delay);
        });
        frames.forEach((frame) => {
          if (!meta.state)
            throw "Convert stop manually, reject when drawImage. " + meta.id;
          context.clearRect(0, 0, width, height);
          context.drawImage(frame, 0, 0, width, height);
          data.push(context.getImageData(0, 0, width, height).data);
        });
        canvas = null;
        debugLog("Convert:", meta.path);
        let worker;
        if (apngWorkers.length === MAX_CONVERT) {
          worker = freeApngWorkers.shift();
        } else {
          worker = new Worker(apngWS);
          apngWorkers.push(worker);
        }
        meta.abort = () => {
          meta.state = 0;
          reject("Convert stop manually, reject when convert apng. " + meta.id);
          worker.terminate();
          apngWorkers.splice(apngWorkers.indexOf(worker), 1);
        };
        worker.onmessage = function (e) {
          if (queue.length) {
            freeApngWorkers.push(worker);
          } else {
            worker.terminate();
            apngWorkers.splice(apngWorkers.indexOf(worker), 1);
          }
          if (!e.data) {
            return reject("apng data is null. " + meta.id);
          }
          const pngBlob = new Blob([e.data], { type: "image/png" });
          if (typeof callBack === "function") callBack(pngBlob, meta);
          resolve();
        };
        const cfg = { data, width, height, delay };
        worker.postMessage(cfg);
      });
    };
    const toWebm = (frames, meta, callBack) => {
      return new Promise((resolve, reject) => {
        let canvas = document.createElement("canvas");
        const width = (canvas.width = frames[0].naturalWidth);
        const height = (canvas.height = frames[0].naturalHeight);
        const context = canvas.getContext("2d");
        const stream = canvas.captureStream();
        const recorder = new MediaRecorder(stream, {
          mimeType: "video/webm",
          videoBitsPerSecond: 80000000,
        });
        const delay = meta.ugoiraMeta.frames.map((frame) => {
          return Number(frame.delay);
        });
        let data = [];
        let frame = 0;
        const displayFrame = () => {
          context.clearRect(0, 0, width, height);
          context.drawImage(frames[frame], 0, 0);
          if (!meta.state) {
            return recorder.stop();
          }
          setTimeout(() => {
            meta.onProgress &&
              meta.onProgress((frame + 1) / frames.length, "webm");
            if (frame === frames.length - 1) {
              return recorder.stop();
            } else {
              frame++;
            }
            displayFrame();
          }, delay[frame]);
        };
        recorder.ondataavailable = (event) => {
          if (event.data && event.data.size) {
            data.push(event.data);
          }
        };
        recorder.onstop = () => {
          if (!meta.state) {
            return reject(
              "Convert stop manually, reject when convert webm." + meta.id
            );
          }
          callBack(new Blob(data, { type: "video/webm" }), meta);
          canvas = null;
          resolve();
        };
        displayFrame();
        recorder.start();
      });
    };
    const convert2 = {
      gif: toGif,
      png: toApng,
      webm: toWebm,
    };
    return {
      add: (blob, meta, callBack) => {
        debugLog("Convert add", meta.path);
        queue.push({ blob, meta, callBack });
        while (active.length < MAX_CONVERT && queue.length && !isStop) {
          convert(queue.shift());
        }
      },
      del: (metas) => {
        if (!metas.length) return;
        isStop = true;
        active = active.filter((meta) => {
          if (metas.includes(meta.meta)) {
            meta.meta.abort();
          } else {
            return true;
          }
        });
        queue = queue.filter((meta) => !metas.includes(meta.meta));
        isStop = false;
        while (active.length < MAX_CONVERT && queue.length) {
          convert(queue.shift());
        }
      },
    };
  }

  function createDownloader(converter) {
    const isBlobDlAvaliable = !(
      navigator.userAgent.includes("Firefox") &&
      GM_info.scriptHandler === "Tampermonkey" &&
      parseFloat(GM_info.version) > 4.17
    );
    const MAX_DOWNLOAD = 5;
    const MAX_RETRY = 3;
    let isStop = false;
    let queue = [];
    let active = [];
    let save;
    if (!isBlobDlAvaliable) {
      debugLog("[pdl]run in firefox && TM version:", GM_info.version);
      save = (blob, meta) => {
        const dlEle = document.createElement("a");
        dlEle.href = URL.createObjectURL(blob);
        dlEle.download = meta.path.slice(meta.path.indexOf("/") + 1);
        dlEle.click();
        URL.revokeObjectURL(dlEle.href);
        meta.resolve(meta);
      };
    } else {
      save = (blob, meta) => {
        const imgUrl = URL.createObjectURL(blob);
        const request = {
          url: imgUrl,
          name: meta.path,
          onerror: (error) => {
            console.log("[pixiv downloader]Error when saving.", meta.path);
            URL.revokeObjectURL(imgUrl);
            meta.reject && meta.reject(error);
          },
          onload: () => {
            if (typeof meta.onLoad === "function") meta.onLoad();
            URL.revokeObjectURL(imgUrl);
            meta.resolve(meta);
          },
        };
        meta.abort = GM_download(request).abort;
      };
    }
    const download = (meta) => {
      debugLog("Download:", meta.path);
      active.push(meta);
      let abortObj;
      if (
        !isBlobDlAvaliable &&
        (meta.illustType !== 2 || convertFormat === "zip")
      ) {
        abortObj = GM_download({
          url: meta.src,
          name: meta.path,
          headers: {
            referer: "https://www.pixiv.net",
          },
          ontimeout: () => {
            debugLog("xmlhttpRequest timeout:", meta.src);
            errHandler(meta);
          },
          onerror: () => {
            debugLog("xmlhttpRequest failed:", meta.src);
            errHandler(meta);
          },
          onload: () => {
            if (typeof meta.onLoad === "function") meta.onLoad();
            meta.resolve(meta);
          },
        });
      } else {
        const request = {
          url: meta.src,
          timeout: 20000,
          method: "GET",
          headers: {
            referer: "https://www.pixiv.net",
          },
          responseType: "blob",
          ontimeout: () => {
            debugLog("xmlhttpRequest timeout:", meta.src);
            errHandler(meta);
          },
          onprogress: (e) => {
            if (e.lengthComputable && typeof meta.onProgress === "function") {
              meta.onProgress(e.loaded / e.total);
            }
          },
          onload: (e) => {
            debugLog("Download complete", meta.path);
            if (!meta.state) return debugLog();
            if (meta.illustType === 2 && convertFormat !== "zip") {
              converter.add(e.response, meta, save);
            } else {
              save(e.response, meta);
            }
            active.splice(active.indexOf(meta), 1);
            if (queue.length && !isStop) download(queue.shift());
          },
          onerror: () => {
            debugLog("xmlhttpRequest failed:", meta.src);
            errHandler(meta);
          },
        };
        abortObj = GM_xmlhttpRequest(request);
      }
      meta.abort = () => {
        meta.state = 0;
        abortObj.abort();
        meta.reject("xhr abort manually. " + meta.src);
        debugLog("xhr abort:", meta.path);
      };
    };
    const add = (metas) => {
      if (metas.length < 1) return;
      const promises = [];
      metas.forEach((meta) => {
        promises.push(
          new Promise((resolve, reject) => {
            meta.state = 1;
            meta.resolve = resolve;
            meta.reject = reject;
          })
        );
      });
      queue = queue.concat(metas);
      while (active.length < MAX_DOWNLOAD && queue.length && !isStop) {
        download(queue.shift());
      }
      return Promise.all(promises);
    };
    const del = (metas) => {
      if (!metas.length) return;
      isStop = true;
      active = active.filter((meta) => {
        if (metas.includes(meta)) {
          meta.abort();
        } else {
          return true;
        }
      });
      queue = queue.filter((meta) => !metas.includes(meta));
      isStop = false;
      while (active.length < MAX_DOWNLOAD && queue.length) {
        download(queue.shift());
      }
    };
    const errHandler = (meta) => {
      if (!meta.retries) {
        meta.retries = 1;
      } else {
        meta.retries++;
      }
      if (meta.retries > MAX_RETRY) {
        meta.reject("xmlhttpRequest failed: " + meta.src);
        console.log("[pixiv downloader]Xml request fail:", meta.path, meta.src);
        active.splice(active.indexOf(meta), 1);
        if (queue.length && !isStop) download(queue.shift());
      } else {
        debugLog("retry xhr", meta.src);
        download(meta);
      }
    };
    return {
      add: add,
      del: del,
    };
  }

  function createParser() {
    const TYPE_ILLUSTS = 0;
    const TYPE_MANGA = 1;
    const TYPE_UGOIRA = 2;
    const replaceInvalidChar = (string) => {
      if (!string) return;
      const temp = document.createElement("div");
      temp.innerHTML = string;
      string = temp.textContent;
      return string
        .trim()
        .replace(/^\.|\.$/g, "")
        .replace(/[\u200b-\u200f\uFEFF\u202a-\u202e\\/:*?|]/g, "")
        .replace(/"/g, "'")
        .replace(/</g, "﹤")
        .replace(/>/g, "﹥");
    };
    const parseByIllust = async (illustId) => {
      const res = await fetch("https://www.pixiv.net/artworks/" + illustId);
      if (!res.ok) throw new Error("fetch artworksURL failed: " + res.status);
      const htmlText = await res.text();
      const preloadData = JSON.parse(
        htmlText.match(/"meta-preload-data" content='(.*)'>/)[1]
      );
      if (!preloadData.illust)
        throw new Error("Fail to parse meta preload data.");
      const illustInfo = preloadData.illust[illustId];
      const user =
        replaceInvalidChar(illustInfo.userName) || "userId-" + illustInfo.userId;
      const title =
        replaceInvalidChar(illustInfo.illustTitle) ||
        "illustId-" + illustInfo.illustId;
      const illustType = illustInfo.illustType;
      const filename = filenamePattern
        .replace("{author}", user)
        .replace("{artist}", user)
        .replace("{artistID}", illustInfo.userId)
        .replace("{title}", title)
        .replace("{id}", illustId);
      let metas = [];
      if (illustType === TYPE_ILLUSTS || illustType === TYPE_MANGA) {
        const firstImgSrc = illustInfo.urls.original;
        const srcPrefix = firstImgSrc.slice(0, firstImgSrc.indexOf("_") + 2);
        const srcSuffix = firstImgSrc.slice(-4);
        for (let i = 0; i < illustInfo.pageCount; i++) {
          metas.push({
            id: illustId,
            illustType: illustType,
            path: user + "/" + filename.replace("{page}", i) + srcSuffix,
            src: srcPrefix + i + srcSuffix,
          });
        }
      }
      if (illustType === TYPE_UGOIRA) {
        const ugoiraRes = await fetch(
          "https://www.pixiv.net/ajax/illust/" + illustId + "/ugoira_meta"
        );
        if (!ugoiraRes.ok)
          throw new Error("fetch ugoira meta failed: " + res.status);
        const ugoira = await ugoiraRes.json();
        metas.push({
          id: illustId,
          illustType: illustType,
          path:
            user + "/" + filename.replace("{page}", "0") + "." + convertFormat,
          src: ugoira.body.originalSrc,
          ugoiraMeta: ugoira.body,
        });
      }
      return metas;
    };
    const parseByUser = async (userId, type) => {
      const res = await fetch(
        "https://www.pixiv.net/ajax/user/" + userId + "/profile/all"
      );
      if (!res.ok) throw new Error("fetch user profile failed: " + res.status);
      const profile = await res.json();
      let illustIds;
      if (type) {
        illustIds = Object.keys(profile.body[type]);
      } else {
        illustIds = Object.keys(profile.body.illusts).concat(
          Object.keys(profile.body.manga)
        );
      }
      return illustIds;
    };
    return {
      id: parseByIllust,
      user: parseByUser,
    };
  }

  const parser = createParser();
  let converter, downloader;
  function initial() {
    return Promise.all([getGifWS(), getApngWS()]).then(([gif, apng]) => {
      const gifWS = URL.createObjectURL(
        new Blob([gif], { type: "text/javascript" })
      );
      const apngWS = URL.createObjectURL(
        new Blob([apng], { type: "text/javascript" })
      );
      converter = createConverter({ gifWS, apngWS });
      downloader = createDownloader(converter);
    });
  }
  function handleDownload(pdlBtn, illustId) {
    let pageCount,
      pageComplete = 0;
    const onProgress = (progress = 0, type = null) => {
      if (pageCount > 1) return;
      progress = Math.floor(progress * 100);
      switch (type) {
        case null:
          pdlBtn.style.setProperty("--pdl-progress", progress + "%");
        case "gif":
        case "webm":
          pdlBtn.textContent = progress;
          break;
        case "zip":
          pdlBtn.textContent = "";
          break;
      }
    };
    const onLoad = function () {
      if (pageCount < 2) return;
      const progress = Math.floor((++pageComplete / pageCount) * 100);
      pdlBtn.textContent = progress;
      pdlBtn.style.setProperty("--pdl-progress", progress + "%");
    };
    pdlBtn.classList.add("pdl-progress");
    parser
      .id(illustId)
      .then((metas) => {
        let shouldDownloadPage;
        if ((shouldDownloadPage = pdlBtn.getAttribute("should-download"))) {
          metas = [metas[shouldDownloadPage]];
        }
        pageCount = metas.length;
        metas.forEach((meta) => {
          meta.onProgress = onProgress;
          meta.onLoad = onLoad;
        });
        return downloader.add(metas);
      })
      .then(() => {
        pixivStorage.add(illustId);
        localStorage.setItem(`pdlTemp-${illustId}`, "");
        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");
      });
  }
  function handleDownloadAll(userId, type = "") {
    let worksCount = 0,
      worksComplete = 0,
      failed = [];
    let isCanceled = false;
    let metasRecord = [];
    const timers = [];
    const placeholder = document.querySelector(".pdl-nav-placeholder");
    const control = document.querySelector(".pdl-stop");
    const isExcludeDled = document.querySelector("#pdl-filter").checked;
    return new Promise((resolve, reject) => {
      control.onclick = () => {
        isCanceled = true;
        for (const timer of timers) {
          if (timer) clearTimeout(timer);
        }
        if (metasRecord.length) {
          downloader.del(metasRecord);
          converter.del(metasRecord);
          metasRecord = [];
        }
        control.onclick = null;
        reject("Download stopped");
      };
      const onProgressCB = (illustId) => {
        placeholder.textContent = `Downloading: ${++worksComplete} / ${worksCount}`;
        if (worksComplete === worksCount - failed.length) {
          if (failed.length) {
            placeholder.textContent = `Complete. Failed: ${failed.length} / ${worksCount}. See console.`;
            console.log("Failed: ", failed.join(", "));
          } else {
            placeholder.textContent = "Complete";
          }
          resolve();
        }
      };
      placeholder.textContent = "Download...";
      parser
        .user(userId, type)
        .then((illustIds) => {
          if (isCanceled) throw "Download stopped";
          if (isExcludeDled) {
            updateHistory();
            debugLog("Before filter", illustIds.length);
            illustIds = illustIds.filter(
              (illustId) => !pixivStorage.has(illustId)
            );
            debugLog("After filter", illustIds.length);
          }
          if (!illustIds.length) throw "All Exclude";
          worksCount = illustIds.length;
          illustIds.forEach((illustId, idx) => {
            if (isCanceled) throw "Download stopped";
            let timer = setTimeout(() => {
              timer = null;
              parser
                .id(illustId)
                .then((metas) => {
                  if (isCanceled) {
                    throw new Error("Download stop manually: " + metas[0].id);
                  }
                  metasRecord = metasRecord.concat(metas);
                  return downloader.add(metas);
                })
                .then((metas) => {
                  pixivStorage.add(illustId);
                  localStorage.setItem(`pdlTemp-${illustId}`, "");
                  if (isCanceled) {
                    return;
                  }
                  metasRecord = metasRecord.filter(
                    (meta) => !metas.includes(meta)
                  );
                  onProgressCB();
                })
                .catch((err) => {
                  failed.push(illustId);
                });
            }, idx * 300);
            timers.push(timer);
          });
        })
        .catch((err) => {
          reject(err);
        });
    });
  }
  function toggleDlAll(evt) {
    const target = evt.target;
    if (target.classList.contains("pdl-btn-all")) {
      evt.preventDefault();
      evt.stopPropagation();
      const dlBarsBtn = target.parentElement.querySelectorAll("[pdl-userid]");
      const placeholder = document.querySelector(".pdl-nav-placeholder");
      const userId = target.getAttribute("pdl-userid");
      dlBarsBtn.forEach((ele) => {
        ele.classList.toggle("pdl-hide");
      });
      handleDownloadAll(userId, target.getAttribute("pdl-type"))
        .catch((err) => {
          placeholder.textContent = err;
        })
        .finally(() => {
          dlBarsBtn.forEach((ele) => {
            ele.classList.toggle("pdl-hide");
          });
        });
    }
  }

  const regexp = {
    artworksPage: /artworks\/(\d+)$/,
    userPage: /users\/(\d+)/,
    ppSearchPage: /\/tags\/.*\/(artworks|illustrations|manga)/,
    bookmarkPage: /users\/\d+\/bookmarks\/artworks/,
    suscribePage: /bookmark_new_illust/,
    activityHref: /illust_id=(\d+)/,
    originSrcPageNum: /(?<=_p)\d+/,
  };

  function getIllustId(node) {
    const isLinkToArtworksPage = regexp.artworksPage.exec(node.href);
    if (isLinkToArtworksPage) {
      if (
        node.getAttribute("data-gtm-value") ||
        node.classList.contains("gtm-illust-recommend-node-node") ||
        node.classList.contains("gtm-discover-user-recommend-node") ||
        node.classList.contains("work")
      ) {
        return isLinkToArtworksPage[1];
      }
    } else {
      const isActivityThumb = regexp.activityHref.exec(node.href);
      if (isActivityThumb && node.classList.contains("work")) {
        return isActivityThumb[1];
      }
    }
    return "";
  }
  function createPdlBtn(
    attributes,
    textContent = "",
    { addEvent } = { addEvent: true }
  ) {
    const ele = document.createElement("button");
    ele.textContent = textContent;
    if (!attributes) return ele;
    const { attrs, classList } = attributes;
    if (classList && classList.length > 0) {
      for (const cla of classList) {
        ele.classList.add(cla);
      }
    }
    if (attrs) {
      for (const key in attrs) {
        ele.setAttribute(key, attrs[key]);
      }
    }
    if (addEvent) {
      ele.addEventListener("click", (evt) => {
        evt.preventDefault();
        evt.stopPropagation();
        const ele = evt.currentTarget;
        if (!evt.currentTarget.classList.contains("pdl-progress")) {
          handleDownload(ele, ele.getAttribute("pdl-id"));
        }
      });
    }
    return ele;
  }
  function createMainBtn(id) {
    if (document.querySelector(".pdl-btn-main")) return;
    const handleBar = document.querySelector("main section section");
    if (handleBar) {
      const pdlBtnWrap = handleBar.lastElementChild.cloneNode();
      const attrs = {
        attrs: { "pdl-id": id },
        classList: ["pdl-btn", "pdl-btn-main"],
      };
      if (pixivStorage.has(id)) attrs.classList.push("pdl-complete");
      pdlBtnWrap.appendChild(createPdlBtn(attrs));
      handleBar.appendChild(pdlBtnWrap);
    }
  }
  function createDownloadBar(userId) {
    const nav = document.querySelector("nav");
    if (!nav || document.querySelector(".pdl-nav-placeholder")) return;
    const fragment = document.createDocumentFragment();
    const placeholder = document.createElement("div");
    placeholder.classList.add("pdl-nav-placeholder");
    fragment.appendChild(placeholder);
    const baseClasses = nav.querySelector("a:not([aria-current])").classList;
    fragment.appendChild(
      createPdlBtn(
        {
          attrs: { "pdl-userId": userId },
          classList: [...baseClasses, "pdl-stop", "pdl-hide"],
        },
        "Stop",
        { addEvent: false }
      )
    );
    fragment.appendChild(
      createPdlBtn(
        {
          attrs: { "pdl-userId": userId },
          classList: [...baseClasses, "pdl-btn-all"],
        },
        "All",
        { addEvent: false }
      )
    );
    if (
      nav.querySelector("a[href$=illustrations]") &&
      nav.querySelector("a[href$=manga]")
    ) {
      fragment.appendChild(
        createPdlBtn(
          {
            attrs: { "pdl-userid": userId, "pdl-type": "illusts" },
            classList: [...baseClasses, "pdl-btn-all"],
          },
          "Illusts",
          { addEvent: false }
        )
      );
      fragment.appendChild(
        createPdlBtn(
          {
            attrs: { "pdl-userid": userId, "pdl-type": "manga" },
            classList: [...baseClasses, "pdl-btn-all"],
          },
          "Manga",
          { addEvent: false }
        )
      );
    }
    const wrapper = document.createElement("div");
    const checkbox = document.createElement("input");
    const label = document.createElement("label");
    wrapper.classList.add("pdl-wrap");
    checkbox.id = "pdl-filter";
    checkbox.type = "checkbox";
    label.setAttribute("for", "pdl-filter");
    label.textContent = "Exclude downloaded";
    wrapper.appendChild(checkbox);
    wrapper.appendChild(label);
    nav.parentElement.insertBefore(wrapper, nav);
    nav.appendChild(fragment);
    nav.addEventListener("click", toggleDlAll);
  }
  function createSubBtn(nodes) {
    const isBookmarkPage = regexp.bookmarkPage.test(location.pathname);
    nodes.forEach((e) => {
      if (e.childElementCount !== 0) {
        const illustId = getIllustId(e);
        if (illustId) {
          const attrs = {
            attrs: { "pdl-id": illustId },
            classList: ["pdl-btn", "pdl-btn-sub"],
          };
          if (pixivStorage.has(illustId)) attrs.classList.push("pdl-complete");
          if (isBookmarkPage) attrs.classList.push("pdl-btn-sub-bookmark");
          e.appendChild(createPdlBtn(attrs));
        }
      }
    });
  }
  function createMultyWorksBtn(id) {
    const works = document.querySelectorAll("[role='presentation'] > a");
    if (works.length < 2) return;
    const containers = Array.from(works).map(
      (node) => node.parentElement.parentElement
    );
    if (containers[0].querySelector(".pdl-btn")) return;
    containers.forEach((node, idx) => {
      const wrapper = document.createElement("div");
      wrapper.classList.add("pdl-wrap-artworks");
      const attrs = {
        attrs: { "pdl-id": id, "should-download": idx },
        classList: ["pdl-btn", "pdl-btn-sub", "artworks"],
      };
      wrapper.appendChild(createPdlBtn(attrs));
      node.appendChild(wrapper);
    });
  }
  const createPresentationBtn = (() => {
    let observer, btn;
    function cb(mutationList) {
      const newImg = mutationList[1]["addedNodes"][0];
      const [pageNum] = regexp.originSrcPageNum.exec(newImg.src);
      const containers = btn.parentElement;
      const attrs = {
        attrs: {
          "pdl-id": btn.getAttribute("pdl-id"),
          "should-download": pageNum,
        },
        classList: ["pdl-btn", "pdl-btn-sub", "presentation"],
      };
      btn.remove();
      btn = createPdlBtn(attrs);
      containers.appendChild(btn);
    }
    return (id) => {
      const containers = document.querySelector(
        "body > [role='presentation'] > div"
      );
      if (!containers) {
        if (observer) {
          observer.disconnect();
          observer = null;
          btn = null;
        }
        return;
      }
      if (containers.querySelector(".pdl-btn")) return;
      const img = containers.querySelector("img");
      const isOriginImg = regexp.originSrcPageNum.exec(img.src);
      if (!isOriginImg) return;
      const [pageNum] = isOriginImg;
      const attrs = {
        attrs: { "pdl-id": id, "should-download": pageNum },
        classList: ["pdl-btn", "pdl-btn-sub", "presentation"],
      };
      btn = createPdlBtn(attrs);
      containers.appendChild(btn);
      observer = new MutationObserver(cb);
      observer.observe(img.parentElement, { childList: true, subtree: true });
    };
  })();
  function createPreviewModalBtn() {
    const modalBtn = document.querySelectorAll(
      ".gtm-manga-viewer-preview-modal-open"
    );
    if (!modalBtn.length) return;
    modalBtn.forEach((node) => {
      node.addEventListener("click", handleModalClick);
    });
  }
  function handleModalClick() {
    const timer = setInterval(() => {
      const ulList = document.querySelectorAll("ul");
      const previewList = ulList[ulList.length - 1];
      if (getComputedStyle(previewList).display !== "grid") return;
      clearInterval(timer);
      const [, id] = regexp.artworksPage.exec(location.pathname);
      previewList.childNodes.forEach((node, idx) => {
        node.style.position = "relative";
        const attrs = {
          attrs: { "pdl-id": id, "should-download": idx },
          classList: ["pdl-btn", "pdl-btn-sub"],
        };
        node.appendChild(createPdlBtn(attrs));
      });
    }, 300);
  }
  function compatPixivPreviewer(nodes) {
    const isPpSearchPage = regexp.ppSearchPage.test(location.pathname);
    if (!isPpSearchPage) return;
    nodes.forEach((node) => {
      const pdlEle = node.querySelector(".pdl-btn");
      if (!pdlEle) return false;
      pdlEle.remove();
    });
  }
  let firstRun = true;
  function observerCallback(records) {
    const addedNodes = [];
    records.forEach((record) => {
      if (!record.addedNodes.length) return;
      record.addedNodes.forEach((node) => {
        if (
          node.nodeType === Node.ELEMENT_NODE &&
          node.tagName !== "BUTTON" &&
          node.tagName !== "IMG"
        ) {
          addedNodes.push(node);
        }
      });
    });
    if (!addedNodes.length) {
      return;
    }
    if (firstRun) {
      createSubBtn(document.querySelectorAll("a"));
      firstRun = false;
    } else {
      compatPixivPreviewer(addedNodes);
      const thunmnails = addedNodes.reduce((prev, current) => {
        return prev.concat(Array.from(current.querySelectorAll("a")));
      }, []);
      createSubBtn(thunmnails);
    }
    const isArtworksPage = regexp.artworksPage.exec(location.pathname);
    const isUserPage = regexp.userPage.exec(location.pathname);
    if (isArtworksPage) {
      const id = isArtworksPage[1];
      createMainBtn(id);
      createMultyWorksBtn(id);
      createPresentationBtn(id);
      createPreviewModalBtn();
    } else if (isUserPage) {
      createDownloadBar(isUserPage[1]);
    }
  }

  addStyle();
  updateHistory();
  GM_registerMenuCommand("Apng", createSetFormatFn("png"), "a");
  GM_registerMenuCommand("Gif", createSetFormatFn("gif"), "g");
  GM_registerMenuCommand("Zip", createSetFormatFn("zip"), "z");
  GM_registerMenuCommand("Webm", createSetFormatFn("webm"), "w");
  GM_registerMenuCommand("Clear history", clearHistory, "c");
  GM_registerMenuCommand("Edit filename", editFilename, "e");
  initial().then(() => {
    new MutationObserver(observerCallback).observe(document.body, {
      childList: true,
      subtree: true,
    });
    document.addEventListener("keydown", (e) => {
      if (e.ctrlKey && e.key === "q") {
        const pdlMainBtn = document.querySelector(".pdl-btn-main");
        if (pdlMainBtn) {
          e.preventDefault();
          if (!e.repeat) {
            pdlMainBtn.dispatchEvent(new MouseEvent("click", { bubbles: true }));
          }
        }
      }
    });
  });

})();