// ==UserScript==
// @name Pixiv Media Downloader
// @namespace Pixiv Media Downloader
// @description Simple media downloader for pixiv.net
// @version 0.4.0
// @icon https://pixiv.net/favicon.ico
// @homepageURL https://github.com/mkm5/pixiv-media-downloader
// @author mkm5
// @license MPL-2.0
// @match https://www.pixiv.net/*
// @run-at document-start
// @noframes
// @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.6.0/jszip.min.js
// @require https://gf.qytechs.cn/scripts/2963-gif-js/code/gifjs.js
// @grant GM_xmlhttpRequest
// ==/UserScript==
const MAX_CANVAS_SIZE = 32757
const ARTWORK_URL = /https:\/\/www\.pixiv\.net\/([a-z]+\/)?artworks\/[0-9]+/
history.pushState = (function (_super) {
return function () {
const funcResult = _super.apply(this, arguments)
if (window.location.href.match(ARTWORK_URL))
scriptInit()
return funcResult
}
})(history.pushState)
async function waitFor(f_condition) {
return new Promise(resolve => {
const interval_id = setInterval(() => {
const result = f_condition()
if (result) {
clearInterval(interval_id)
resolve(result)
}
}, 150)
})
}
async function setupObserver(target, func) {
new MutationObserver(func)
.observe(target, {
childList: true,
subtree: true,
attributes: true
})
}
function createButton(text, onclick) {
const button = document.createElement('button')
button.type = 'button'
button.innerText = text
button.onclick = onclick
button.style.marginRight = '10px'
button.style.display = 'inline-block'
button.style.height = '32px'
button.style.lineHeight = '32px'
button.style.border = 'none'
button.style.background = 'none'
button.style.color = 'inherit'
button.style.fontWeight = '700'
button.style.cursor = 'pointer'
button._setup = function () { this._ot = this.innerText; this.disabled = true; return this }
button._reset = function () { this.innerText = this._ot; this.disabled = false }
button._update = function (t) { this.innerText = this._ot + t }
return button
}
function createCheckbox(n, onchange) {
const checkbox = document.createElement('input')
checkbox.type = 'checkbox'
checkbox.checked = true
checkbox.style.position = 'absolute'
// NOTE: Images (except for the first one) are splitted by 40px top margin
checkbox.style.top = 0 + (n === 0 ? 0 : 40) + 'px'
checkbox.onchange = onchange
return checkbox
}
function saveFile(filename, data) {
const link = document.createElement('a')
link.href = URL.createObjectURL(data)
link.download = filename
link.click()
URL.revokeObjectURL(link.href)
link.remove()
}
async function requestImage(url) {
return new Promise(resolve => {
GM_xmlhttpRequest({
method: 'GET',
url,
responseType: 'blob',
headers: { Referer: 'https://www.pixiv.net/' },
onload: (req) => {
console.log(`[${req.statusText}:${req.status}] ${req.finalUrl}`)
if (req.status === 200) {
resolve(req.response)
}
}
})
})
}
async function loadImage(src) {
return new Promise(resolve => {
const img = new Image()
img.onload = () => resolve(img)
img.src = src
})
}
async function fetchImages(urls, on_fetch_call) {
return Promise.all(
urls.map(([idx, url]) => {
return new Promise(resolve => {
requestImage(url)
.then(data => {
const resolved = on_fetch_call(idx, data, resolve)
if (!resolved)
resolve([idx, data])
})
})
})
)
}
function* urlsGen(url, is_image_included) {
for (let idx = 0; idx < is_image_included.length; idx++) {
if (is_image_included[idx])
yield [idx, url.replace(/p\d+/, `p${idx}`)]
}
}
(async function scriptInit() {
if (!window.location.href.match(ARTWORK_URL))
return
if (typeof GIF === 'undefined') {
const gif_script = document.createElement('script')
gif_script.src = 'https://cdnjs.cloudflare.com/ajax/libs/gif.js/0.2.0/gif.js'
document.head.appendChild(gif_script)
}
const image_id = document.URL.split('/').pop()
const illust_data_response = await fetch(`https://www.pixiv.net/ajax/illust/${image_id}`)
const illust_data = (await illust_data_response.json()).body
console.log('Fetched data:', illust_data)
const filename = `${illust_data.illustTitle},${illust_data.illustId}-[${illust_data.userName}]-(${illust_data.createDate.split('T')[0]})`
const button_section = await waitFor(() => {
const sections = document.querySelectorAll('section')
/* NOTE: (childElementCount) 3 for guests, 4 for logged users */
return sections && sections.length >= 2 && sections[1].childElementCount >= 3
? sections[1] : undefined
})
if (illust_data.illustType === 0 || illust_data.illustType === 1) /* Picture & Manga */ {
const url = illust_data.urls.original
const extension = url.split('.').pop()
if (illust_data.pageCount === 1) /* Single image mode */ {
button_section.appendChild(createButton('Download original', async function () {
const btn = this._setup()
requestImage(url).then(data => {
saveFile(`${filename}.${extension}`, data)
btn._reset()
})
}))
return;
}
const is_image_included = []
for (let i = 0; i < illust_data.pageCount; i ++)
is_image_included.push(true)
const figure = await waitFor(() => {
return document.querySelector('figure')
})
setupObserver(figure, (_, observer) => {
const container = figure.firstChild
if (container?.children.length - 2 === illust_data.pageCount) {
for (let i = 0; i < illust_data.pageCount; i++) {
const checkbox = createCheckbox(i, function () {
is_image_included[i] = this.checked
})
// NOTE: First images is actually a second element of container
container.children[i + 1].appendChild(checkbox)
}
// NOTE: Making a space for a checkboxes, so they can be clicked on
container.lastChild.style.left = '25px'
observer.disconnect()
}
})
button_section.appendChild(createButton('Download separately', async function () {
const btn = this._setup()
let i = 0
await fetchImages([...urlsGen(url, is_image_included)], (idx, data) => {
const percents = Math.round((++i / illust_data.pageCount) * 100)
btn._update(` [${percents}%]`)
saveFile(`${filename}.p${idx}.${extension}`, data)
})
btn._reset()
}))
button_section.appendChild(createButton('Download zip', async function () {
const btn = this._setup()
const zip = new JSZip()
let i = 0
await fetchImages([...urlsGen(url, is_image_included)], (idx, data) => {
const percents = Math.round((++i / illust_data.pageCount) * 100)
btn._update(` [${percents}%]`)
zip.file(`${filename}.p${idx}.${extension}`, data, { binary: true })
})
zip.generateAsync({ type: 'blob' }).then(content => {
saveFile(`${filename}.zip`, content)
btn._reset()
})
}))
button_section.appendChild(createButton('Download continuous', async function () {
const btn = this._setup()
const canvas = document.createElement('canvas')
canvas.width = 0
canvas.height = 0
const context = canvas.getContext('2d')
let i = 0
const images = await fetchImages([...urlsGen(url, is_image_included)], (_, data, resolve) => {
const object_url = URL.createObjectURL(data)
loadImage(object_url).then(image => {
if (canvas.width < image.width)
canvas.width = image.width
canvas.height += image.height
resolve(image)
URL.revokeObjectURL(object_url)
})
const percents = Math.round((++i / illust_data.pageCount) * 70)
btn._update(` [${percents}%]`)
return true
})
// TODO: Break image loading process when error occures
if (canvas.height > MAX_CANVAS_SIZE || canvas.width > MAX_CANVAS_SIZE) {
btn._rest()
alert('[Error] Image height would exceed the limit. Aborting.')
return;
}
let k = 0
let current_position = 0
for (const image of images) {
const percents = Math.round(70 + (++k / illust_data.pageCount) * 30)
btn._update(` [${percents}%]`)
context.drawImage(image, Math.round((canvas.width - image.width) / 2), current_position)
current_position += image.height
}
canvas.toBlob(blob => {
saveFile(`${filename}.${extension}`, blob)
btn._reset()
})
}))
}
else if (illust_data.illustType === 2) /* Ugoira */ {
const ugoira_meta_response = await fetch(`https://www.pixiv.net/ajax/illust/${image_id}/ugoira_meta`)
const ugoira_meta_data = (await ugoira_meta_response.json()).body
button_section.appendChild(createButton('Download GIF', async function () {
const btn = this._setup()
btn._update(' [0%]')
const zip_file_response = await fetch(ugoira_meta_data.originalSrc)
btn._update(' [10%]')
const zip_blob = await zip_file_response.blob()
btn._update(' [15%]')
const zip = await new JSZip().loadAsync(zip_blob)
btn._update(' [20%]')
const gif = new GIF({ workers: 6, quality: 10, workerScript: GIF_worker_URL })
gif.on('finished', blob => {
saveFile(`${filename}.gif`, blob)
})
gif.on('progress', p => {
const percents = Math.round(25 + p * 75)
btn._update(` [${percents}%]`)
})
const frames = await Promise.all(
ugoira_meta_data.frames.map((frame, idx) => {
return new Promise(resolve => {
zip.file(frame.file).async('blob')
.then(data => {
const url = URL.createObjectURL(data)
loadImage(url)
.then(image => {
resolve({ idx, image, delay: frame.delay })
URL.revokeObjectURL(url)
})
})
})
})
)
for (const frame of frames) {
gif.addFrame(frame.image, { delay: frame.delay })
}
btn._update(' [25%]')
gif.render()
}))
}
})()