Pixiv 簡單存圖

透過快捷鍵與自訂名稱格式來簡單的存圖

目前為 2018-07-31 提交的版本,檢視 最新版本

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

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

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

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

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

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

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

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

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

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

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

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

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

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

// ==UserScript==
// @name         Pixiv easy save image
// @name:zh-TW   Pixiv 簡單存圖
// @name:zh-CN   Pixiv 简单存图
// @namespace    https://blog.maple3142.net/
// @version      0.4.0
// @description  Save pixiv image easily with custom name format and shortcut key.
// @description:zh-TW  透過快捷鍵與自訂名稱格式來簡單的存圖
// @description:zh-CN  透过快捷键与自订名称格式来简单的存图
// @author       maple3142
// @require      https://greasyfork.org/scripts/370765-gif-js-for-user-js/code/gifjs%20for%20userjs.js?version=616920
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.1.5/jszip.min.js
// @match        https://www.pixiv.net/member_illust.php?mode=medium&illust_id=*
// @match        https://www.pixiv.net/
// @match        https://www.pixiv.net/bookmark.php*
// @match        https://www.pixiv.net/new_illust.php*
// @match        https://www.pixiv.net/bookmark_new_illust.php*
// @match        https://www.pixiv.net/ranking.php*
// @match        https://www.pixiv.net/search.php*
// @connect      pximg.net
// @grant        unsafeWindow
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @compatible   firefox >=52
// @compatible   chrome >=55
// @license      MIT
// ==/UserScript==

;(function() {
	'use strict'
	const FORMAT = {
		single: '{{title}}-{{userName}}-{{id}}',
		multiple: '{{title}}-{{userName}}-{{id}}-p{{#}}'
	}
	const KEYCODE_TO_SAVE = 83 // 83 is 's' key

	const $ = s => document.querySelector(s)
	const $$ = s => [...document.querySelectorAll(s)]
	const elementmerge = (a, b) => {
		Object.keys(b).forEach(k => {
			if (typeof b[k] === 'object') elementmerge(a[k], b[k])
			else if (k in a) a[k] = b[k]
			else a.setAttribute(k, b[k])
		})
	}
	const $el = (s, o) => {
		const el = document.createElement(s)
		elementmerge(el, o)
		return el
	}
	const debounce = delay => fn => {
		let de = false
		return (...args) => {
			if (de) return
			de = true
			fn(...args)
			setTimeout(() => (de = false), delay)
		}
	}
	const download = (url, fname) => {
		const a = $el('a', { href: url, download: fname || true })
		document.body.appendChild(a)
		a.click()
		document.body.removeChild(a)
	}
	const blobToImg = blob =>
		new Promise((res, rej) => {
			const src = URL.createObjectURL(blob)
			const img = $el('img', { src })
			img.onload = () => {
				URL.revokeObjectURL(src)
				res(img)
			}
			img.onerror = e => {
				URL.revokeObjectURL(src)
				rej(e)
			}
		})
	const gmxhr = o => new Promise((res, rej) => GM_xmlhttpRequest({ ...o, onload: res, onerror: rej }))
	const getJSON = url => fetch(url, { credentials: 'same-origin' }).then(r => r.json())
	const getJSONBody = url => getJSON(url).then(r => r.body)
	const getIllustData = id => getJSONBody(`/ajax/illust/${id}`)
	const getUgoiraMeta = id => getJSONBody(`/ajax/illust/${id}/ugoira_meta`)
	const getCrossOriginBlob = (url, Referer = 'https://www.pixiv.net/') =>
		gmxhr({ method: 'GET', url, responseType: 'blob', headers: { Referer } })
	const saveImage = ({ single, multiple }, id) =>
		getIllustData(id)
			.then(data => {
				const { illustType } = data
				switch (illustType) {
					case 0:
					case 1:
						{
							// normal
							const f = data.pageCount === 1 ? single : multiple
							const fname = f.replace(/{{(\w+?)}}/g, (m, g1) => data[g1])
							const url = data.urls.original
							const ext = url
								.split('/')
								.pop()
								.split('.')
								.pop()
							if (data.pageCount === 1) {
								return Promise.all([Promise.all([fname + '.' + ext, getCrossOriginBlob(url)])])
							} else {
								const rgxr = /{{#(\d+)}}/.exec(multiple)
								let offset = 0
								if (rgxr) {
									offset = parseInt(rgxr[1])
								}
								const len = (data.pageCount + offset) / 10 + 1
								const ar = []
								for (let i = offset; i < data.pageCount + offset; i++) {
									const num = i.toString().padStart(len, '0')
									ar.push(
										Promise.all([
											`${fname.replace(/{{#(\d+)?}}/g, num)}.${ext}`,
											getCrossOriginBlob(url.replace('p0', `p${i}`)).then(xhr => xhr.response)
										])
									)
								}
								return Promise.all(ar)
							}
						}
						break
					case 2: {
						// ugoira
						const fname = single.replace(/{{(\w+?)}}/g, (m, g1) => data[g1])
						const gif = new GIF({ workers: 10, quality: 10 })
						const ugoiraMeta = getUgoiraMeta(id)
						const ugoiraZip = ugoiraMeta.then(data => fetch(data.src).then(r => r.blob()))
						const gifFrames = ugoiraZip
							.then(JSZip.loadAsync)
							.then(({ files }) =>
								Promise.all(Object.values(files).map(f => f.async('blob').then(blobToImg)))
							)
						return Promise.all([ugoiraMeta, gifFrames])
							.then(
								([data, frames]) =>
									new Promise((res, rej) => {
										{
											for (let i = 0; i < frames.length; i++) {
												gif.addFrame(frames[i], { delay: data.frames[i].delay })
											}
											gif.on('finished', res)
											gif.on('error', rej)
											gif.render()
										}
									})
							)
							.then(gifBlob => [[fname + '.gif', gifBlob]])
					}
				}
			})
			.then(results => {
				for (const [f, blob] of results) {
					const url = URL.createObjectURL(blob)
					download(url, f)
					URL.revokeObjectURL(url)
				}
			})

	if (location.pathname === '/member_illust.php') {
		const observer = new MutationObserver(
			debounce(10)(mut => {
				const menu = $('ul[role=menu]')
				if (!menu) return
				const n = menu.children.length
				const item = $el('li', {
					role: 'menuitem',
					onclick: () => saveImage(FORMAT, new URLSearchParams(location.search).get('illust_id'))
				})
				item.className = menu.children[n - 2].className
				const text = $el('span', { textContent: '⬇' })
				item.appendChild(text)
				menu.insertBefore(item, menu.children[n - 1])
			})
		)
		observer.observe(document.body, { childList: true, subtree: true })
	}

	// key shortcut
	{
		const SELECTOR_MAP = {
			'/': 'a.work:hover,a._work:hover',
			'/bookmark.php': 'a.work:hover',
			'/new_illust.php': 'a.work:hover',
			'/bookmark_new_illust.php': 'a.work:hover,.gtm-recommend-illust.gtm-thumbnail-link:hover',
			'/member_illust.php': 'figure>div[role=presentation]>div>a:hover',
			'/ranking.php': 'a.work:hover',
			'/search.php': '#js-react-search-mid a:hover,a.work:hover'
		}
		const selector = SELECTOR_MAP[location.pathname]
		addEventListener('keydown', e => {
			if (e.which !== KEYCODE_TO_SAVE) return // 's' key
			let id
			if (typeof selector === 'string') {
				const el = $(selector)
				if (!el) return
				id = /\d+/.exec(el.href.split('/').pop())[0]
			} else {
				id = selector()
			}
			if (id) saveImage(FORMAT, id)
		})
	}

	// support Patchouli
	{
		let times = 0
		const it = setInterval(() => {
			if (times >= 10) clearInterval(it)
			if (typeof Patchouli !== 'undefined' && Patchouli._isMounted) {
				$$('.image-flexbox').map(x => x.classList.add('work'))
				const observer = new MutationObserver(
					debounce(10)(mut => $$('.image-flexbox').map(x => x.classList.add('work')))
					// add class=work to let them works
				)
				observer.observe(Patchouli.$el, { childList: true, subtree: true })
				console.log('Pixiv easy save image: Patchouli detected!')
				clearInterval(it)
				GM_addStyle(`.image-item .work{margin-bottom:0px!important;}`) // disable default css
			}
			times++
		}, 1000)
	}
})()