Pixiv Downloader

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

As of 2022-07-21. See the latest version.

// ==UserScript==
// @name         Pixiv Downloader
// @namespace    https://greasyfork.org/zh-CN/scripts/432150
// @version      0.4.3
// @description  一键(快捷键)下载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_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 () {
  let convertFormat =
    localStorage.pdlFormat || (localStorage.pdlFormat = "zip");
  let filenamePattern =
    localStorage.pdlFilename ||
    (localStorage.pdlFilename = "{author}_{title}_{id}_p{page}");
  let gifWS, apngWS;
  if (!(gifWS = await GM_getValue("gifWS"))) {
    gifWS = await fetch(
      "https://raw.githubusercontent.com/jnordberg/gif.js/master/dist/gif.worker.js"
    )
      .then((res) => res.blob())
      .then((blob) => blob.text());
    GM_setValue("gifWS", gifWS);
  }
  if (!(apngWS = await GM_getValue("apngWS"))) {
    const pako = await fetch(
      "https://cdnjs.cloudflare.com/ajax/libs/pako/2.0.4/pako.min.js"
    ).then((res) => res.text());
    const upng = await fetch(
      "https://cdnjs.cloudflare.com/ajax/libs/upng-js/2.1.0/UPNG.min.js"
    )
      .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);
  }
  gifWS = URL.createObjectURL(new Blob([gifWS], { type: "text/javascript" }));
  apngWS = URL.createObjectURL(new Blob([apngWS], { type: "text/javascript" }));
  const converter = (() => {
    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());
        }
      },
    };
  })();
  const parser = (() => {
    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("{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 downloader = (() => {
    const MAX_DOWNLOAD = 5;
    const MAX_RETRY = 3;
    let isStop = false;
    let queue = [];
    let active = [];
    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);
      }
    };
    const 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);
          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);
      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);
        },
      };
      const 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());
      }
    };
    return {
      add: add,
      del: del,
    };
  })();
  const getIllustId = (thumbnail) => {
    if (thumbnail.childElementCount === 0) return false;
    const isHrefMatch = /artworks\/(\d+)$/.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 (
        location.href.indexOf("bookmark_new_illust") !== -1 &&
        thumbnail.getAttribute("class")
      ) {
        return isHrefMatch[1];
      }
    } else {
      const isActivityMatch = /illust_id=(\d+)/.exec(thumbnail.href);
      if (isActivityMatch && thumbnail.classList.contains("work")) {
        return isActivityMatch[1];
      }
    }
    return "";
  };
  const createPdlBtn = (ele, attributes, textContent = "") => {
    if (!ele) ele = document.createElement("a");
    ele.href = "javascript:void(0)";
    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]);
      }
    }
    return ele;
  };
  const 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) => {
        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");
      });
  };
  const handleDownloadAll = (userId, type = "") => {
    let worksCount = 0,
      worksComplete = 0,
      failed = 0;
    let isCanceled = false;
    let metasRecord = [];
    const timers = [];
    const placeholder = body.querySelector(".pdl-nav-placeholder");
    const control = body.querySelector(".pdl-btn-control");
    const isExcludeDled = body.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) {
          placeholder.textContent =
            worksComplete === worksCount
              ? "Complete"
              : "Incomplete, see console.";
          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 "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 "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)
                    throw (
                      "download stopped already, will not update progress." +
                      illustId
                    );
                  metasRecord = metasRecord.filter(
                    (meta) => !metas.includes(meta)
                  );
                  onProgressCB();
                })
                .catch((err) => {
                  failed++;
                });
            }, idx * 300);
            timers.push(timer);
          });
        })
        .catch((err) => {
          reject(err);
        });
    });
  };
  const toggleDlAll = (evt) => {
    const target = evt.target;
    if (target.classList.contains("pdl-btn-all")) {
      evt.stopPropagation();
      const dlBars = target.parentElement.querySelectorAll("[pdl-userid]");
      const placeholder = body.querySelector(".pdl-nav-placeholder");
      const userId = target.getAttribute("pdl-userid");
      dlBars.forEach((ele) => {
        ele.classList.toggle("pdl-hide");
      });
      handleDownloadAll(userId, target.getAttribute("pdl-type"))
        .catch((err) => {
          placeholder.textContent = err;
        })
        .finally(() => {
          dlBars.forEach((ele) => {
            ele.classList.toggle("pdl-hide");
          });
        });
    }
  };
  const editFilename = () => {
    const newPattern = prompt(
      `Default: {author}_{title}_{id}_p{page}`,
      filenamePattern
    );
    if (!newPattern) return;
    localStorage.pdlFilename = filenamePattern = newPattern
      .trim()
      .replace(/^\.|[\u200b-\u200f\uFEFF\u202a-\u202e\\/:*?"|<>]/g, "");
  };
  const observerCallback = () => {
    const isArtworksPage = /artworks\/(\d+)$/.exec(location.href);
    const isUserPage = /users\/(\d+)/.exec(location.pathname);
    const isBookmarkPage = /users\/\d+\/bookmarks\/artworks/.test(
      location.pathname
    );
    if (isArtworksPage && !body.querySelector(".pdl-btn-main")) {
      const handleBar = body.querySelector("main section section");
      if (handleBar) {
        const pdlBtnWrap = handleBar.lastElementChild.cloneNode();
        const attrs = {
          attrs: { "pdl-id": isArtworksPage[1] },
          classList: ["pdl-btn", "pdl-btn-main"],
        };
        if (pixivStorage.has(isArtworksPage[1]))
          attrs.classList.push("pdl-complete");
        pdlBtnWrap.appendChild(createPdlBtn(null, attrs));
        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 })
                );
              }
            }
          }
        });
      }
    }
    body.querySelectorAll("a").forEach((e) => {
      if (!e.querySelector(".pdl-btn-sub")) {
        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-self");
          e.appendChild(createPdlBtn(null, attrs));
        }
      }
    });
    if (isUserPage) {
      const nav = body.querySelector("nav");
      if (!nav || body.querySelector(".pdl-nav-placeholder")) return;
      const fragment = document.createDocumentFragment();
      const placeholder = document.createElement("div");
      placeholder.classList.add("pdl-nav-placeholder");
      fragment.appendChild(placeholder);
      const baseEle = nav.querySelector("a:not([aria-current])").cloneNode();
      fragment.appendChild(
        createPdlBtn(
          baseEle.cloneNode(),
          {
            attrs: { "pdl-userId": isUserPage[1] },
            classList: ["pdl-btn-control", "pdl-stop", "pdl-hide"],
          },
          "Stop"
        )
      );
      fragment.appendChild(
        createPdlBtn(
          baseEle.cloneNode(),
          {
            attrs: { "pdl-userId": isUserPage[1] },
            classList: ["pdl-btn-all"],
          },
          "All"
        )
      );
      if (
        nav.querySelector("a[href$=illustrations]") &&
        nav.querySelector("a[href$=manga]")
      ) {
        fragment.appendChild(
          createPdlBtn(
            baseEle.cloneNode(),
            {
              attrs: { "pdl-userid": isUserPage[1], "pdl-type": "illusts" },
              classList: ["pdl-btn-all"],
            },
            "Illusts"
          )
        );
        fragment.appendChild(
          createPdlBtn(
            baseEle.cloneNode(),
            {
              attrs: { "pdl-userid": isUserPage[1], "pdl-type": "manga" },
              classList: ["pdl-btn-all"],
            },
            "Manga"
          )
        );
      }
      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);
    }
  };
  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.pixivDownloader = localStorage.pixivDownloader || "[]";
  let pixivStorage = new Set(JSON.parse(localStorage.pixivDownloader));
  updateHistory();
  GM_registerMenuCommand(
    "Apng",
    () => {
      convertFormat = localStorage.pdlFormat = "png";
    },
    "a"
  );
  GM_registerMenuCommand(
    "Gif",
    () => {
      convertFormat = localStorage.pdlFormat = "gif";
    },
    "g"
  );
  GM_registerMenuCommand(
    "Zip",
    () => {
      convertFormat = localStorage.pdlFormat = "zip";
    },
    "z"
  );
  GM_registerMenuCommand(
    "Webm",
    () => {
      convertFormat = localStorage.pdlFormat = "webm";
    },
    "w"
  );
  GM_registerMenuCommand(
    "Clear history",
    () => {
      updateHistory();
      pixivStorage = new Set();
      localStorage.pixivDownloader = "[]";
    },
    "c"
  );
  GM_registerMenuCommand(
    "Edit filename",
    () => {
      editFilename();
    },
    "e"
  );
  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-btn-sub-self.pdl-btn-sub-self {
    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::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);
}`;
  document.head.appendChild(pdlStyle);
  const body = document.body;
  new MutationObserver(observerCallback).observe(body, {
    attributes: false,
    childList: true,
    subtree: true,
  });
  body.addEventListener("click", (event) => {
    const pdlNode = event.target;
    if (pdlNode.hasAttribute("pdl-id")) {
      event.stopPropagation();
      if (!pdlNode.classList.contains("pdl-progress")) {
        handleDownload(pdlNode, pdlNode.getAttribute("pdl-id"));
      }
    }
  });
  function debugLog(...msgs) {}
})();