Pixiv Media Downloader

Simple media downloader for pixiv.net

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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://greasyfork.org/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()
    }))
  }
})()