Pixiv Downloader

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

À partir de 2022-05-28. 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.4.1
// @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, reject) => {
              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, progress);
            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, apngWorkers.length);
        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);
          debugLog('abort: apngWorkers.length', apngWorkers.length);
        };
        worker.onmessage = function (e) {
          if (queue.length) {
            freeApngWorkers.push(worker);
          } else {
            worker.terminate();
            apngWorkers.splice(apngWorkers.indexOf(worker), 1);
            debugLog('complete: apngWorkers.length', apngWorkers.length);
          }
          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;
        let timer;

        const displayFrame = () => {
          context.clearRect(0, 0, width, height);
          context.drawImage(frames[frame], 0, 0);
          if (!meta.state) {
            return recorder.stop();
          }
          timer = setTimeout(() => {
            timer = null;
            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('meta.state = 0, stop already');
          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: (error) => {
          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) => {
      debugLog('Downloader 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) => {
        debugLog('update progress by', 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(illustId);
                })
                .catch((err) => {
                  debugLog(err);
                  failed++;
                });
            }, idx * 300);
            timers.push(timer);
          });
        })
        .catch((err) => {
          debugLog(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, ""); 
    debugLog(filenamePattern);
  };

  const observerCallback = () => {
    const isArtworksPage = /artworks\/(\d+)$/.exec(location.href);
    const isUserPage = /users\/(\d+)/.exec(location.pathname);

    if (isArtworksPage && !body.querySelector('.pdl-btn-main')) {
      const addFavousBtn = body.querySelector('.gtm-main-bookmark');
      if (addFavousBtn) {
        const handleBar = addFavousBtn.parentElement.parentElement;
        const pdlBtnWrap = addFavousBtn.parentElement.cloneNode();
        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');
          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]);
  }

  const isDebug = false;
  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-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) {
    if (isDebug) console.log(...msgs);
  }
})();