// ==UserScript==
// @name Pixiv Downloader
// @namespace https://greasyfork.org/zh-CN/scripts/432150
// @version 0.5.0
// @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_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;
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.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::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 = "{author}_{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}`, filenamePattern);
if (!newPattern) return;
localStorage.pdlFilename = filenamePattern = newPattern
.trim()
.replace(/^\.|[\u200b-\u200f\uFEFF\u202a-\u202e\\/:*?"|<>]/g, "");
}
async function getGifWS() {
const Url =
"https://raw.githubusercontent.com/jnordberg/gif.js/master/dist/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 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,
};
}
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("{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 setup() {
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 = 0;
let isCanceled = false;
let metasRecord = [];
const timers = [];
const placeholder = document.querySelector(".pdl-nav-placeholder");
const control = document.querySelector(".pdl-btn-control");
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) {
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);
});
});
}
function toggleDlAll(evt) {
const target = evt.target;
if (target.classList.contains("pdl-btn-all")) {
evt.stopPropagation();
const dlBars = target.parentElement.querySelectorAll("[pdl-userid]");
const placeholder = document.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 regexp = {
artworksPage: /artworks\/(\d+)$/,
userPage: /users\/(\d+)/,
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(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]);
}
}
ele.addEventListener("click", (event) => {
event.preventDefault();
});
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(null, 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 baseEle = nav.querySelector("a:not([aria-current])").cloneNode();
fragment.appendChild(
createPdlBtn(
baseEle.cloneNode(),
{
attrs: { "pdl-userId": userId },
classList: ["pdl-btn-control", "pdl-stop", "pdl-hide"],
},
"Stop"
)
);
fragment.appendChild(
createPdlBtn(
baseEle.cloneNode(),
{
attrs: { "pdl-userId": userId },
classList: ["pdl-btn-all"],
},
"All"
)
);
if (
nav.querySelector("a[href$=illustrations]") &&
nav.querySelector("a[href$=manga]")
) {
fragment.appendChild(
createPdlBtn(
baseEle.cloneNode(),
{
attrs: { "pdl-userid": userId, "pdl-type": "illusts" },
classList: ["pdl-btn-all"],
},
"Illusts"
)
);
fragment.appendChild(
createPdlBtn(
baseEle.cloneNode(),
{
attrs: { "pdl-userid": userId, "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);
}
function createSubBtn(nodes) {
const isBookmarkPage = regexp.bookmarkPage.test(location.pathname);
nodes.forEach((e) => {
if (e.childElementCount !== 0 && !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-bookmark");
e.appendChild(createPdlBtn(null, 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(null, 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(null, 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(null, 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"],
};
const ele = createPdlBtn(null, attrs);
ele.addEventListener("click", (evt) => {
const ele = evt.currentTarget;
if (!ele.classList.contains("pdl-progress")) {
handleDownload(ele, ele.getAttribute("pdl-id"));
}
});
node.appendChild(ele);
});
}, 300);
}
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 !== "A" &&
node.tagName !== "IMG"
) {
addedNodes.push(node);
}
});
});
if (!addedNodes.length) {
return;
}
if (firstRun) {
createSubBtn(document.querySelectorAll("a:not(.pdl-btn)"));
firstRun = false;
} else {
const thunmnails = addedNodes.reduce((prev, current) => {
return prev.concat(
Array.from(current.querySelectorAll("a:not(.pdl-btn)"))
);
}, []);
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");
setup().then(() => {
new MutationObserver(observerCallback).observe(document.body, {
childList: true,
subtree: true,
});
document.addEventListener("click", (event) => {
const pdlNode = event.target;
if (pdlNode.hasAttribute("pdl-id")) {
event.preventDefault();
if (!pdlNode.classList.contains("pdl-progress")) {
handleDownload(pdlNode, pdlNode.getAttribute("pdl-id"));
}
}
});
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 }));
}
}
}
});
});
})();