Pixiv easy save image

Save pixiv image easily with custom name format and shortcut key.

  1. // ==UserScript==
  2. // @name Pixiv easy save image
  3. // @name:zh-TW Pixiv 簡單存圖
  4. // @name:zh-CN Pixiv 简单存图
  5. // @namespace https://blog.maple3142.net/
  6. // @version 0.6.3
  7. // @description Save pixiv image easily with custom name format and shortcut key.
  8. // @description:zh-TW 透過快捷鍵與自訂名稱格式來簡單的存圖
  9. // @description:zh-CN 透过快捷键与自订名称格式来简单的存图
  10. // @author maple3142
  11. // @require https://greatest.deepsurf.us/scripts/370765-gif-js-for-user-js/code/gifjs%20for%20userjs.js?version=616920
  12. // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.1.5/jszip.min.js
  13. // @require https://unpkg.com/xfetch-js@0.3.0/xfetch.min.js
  14. // @require https://unpkg.com/@snackbar/core@1.7.0/dist/snackbar.min.js
  15. // @require https://bundle.run/filenamify@4.1.0
  16. // @require https://unpkg.com/gmxhr-fetch@0.0.6/gmxhr-fetch.min.js
  17. // @require https://gitcdn.xyz/repo/antimatter15/whammy/27ef01b3d82e9b32c7822f7a5250809e1ae89b33/whammy.js
  18. // @match https://www.pixiv.net/member_illust.php?mode=medium&illust_id=*
  19. // @match https://www.pixiv.net/
  20. // @match https://www.pixiv.net/bookmark.php*
  21. // @match https://www.pixiv.net/new_illust.php*
  22. // @match https://www.pixiv.net/bookmark_new_illust.php*
  23. // @match https://www.pixiv.net/ranking.php*
  24. // @match https://www.pixiv.net/search.php*
  25. // @match https://www.pixiv.net/artworks*
  26. // @match https://www.pixiv.net/member.php*
  27. // @connect pximg.net
  28. // @grant GM_xmlhttpRequest
  29. // @compatible firefox >=52
  30. // @compatible chrome >=55
  31. // @license MIT
  32. // ==/UserScript==
  33.  
  34. ;(function() {
  35. 'use strict'
  36. const FORMAT = {
  37. single: d => `${d.title}-${d.userName}-${d.id}`,
  38. multiple: (d, i) => `${d.title}-${d.userName}-${d.id}-p${i}`
  39. }
  40. const KEYCODE_TO_SAVE = 83 // 83 is 's' key
  41. const SAVE_UGOIRA_AS_WEBM = false // faster than gif
  42. const USE_PIXIVCAT = true // much faster than pximg
  43.  
  44. const gxf = xf.extend({ fetch: gmfetch })
  45. const $ = s => document.querySelector(s)
  46. const $$ = s => [...document.querySelectorAll(s)]
  47. const elementmerge = (a, b) => {
  48. Object.keys(b).forEach(k => {
  49. if (typeof b[k] === 'object') elementmerge(a[k], b[k])
  50. else if (k in a) a[k] = b[k]
  51. else a.setAttribute(k, b[k])
  52. })
  53. }
  54. const $el = (s, o = {}) => {
  55. const el = document.createElement(s)
  56. elementmerge(el, o)
  57. return el
  58. }
  59. const download = (url, fname) => {
  60. const a = $el('a', { href: url, download: fname || true })
  61. document.body.appendChild(a)
  62. a.click()
  63. document.body.removeChild(a)
  64. }
  65. const downloadBlob = (blob, fname) => {
  66. const url = URL.createObjectURL(blob)
  67. download(url, fname)
  68. URL.revokeObjectURL(url)
  69. }
  70. const blobToCanvas = blob =>
  71. new Promise((res, rej) => {
  72. const src = URL.createObjectURL(blob)
  73. const img = $el('img', { src })
  74. const cvs = $el('canvas')
  75. const ctx = cvs.getContext('2d')
  76. img.onload = () => {
  77. URL.revokeObjectURL(src)
  78. cvs.height = img.naturalHeight
  79. cvs.width = img.naturalWidth
  80. ctx.drawImage(img, 0, 0)
  81. res(cvs)
  82. }
  83. img.onerror = e => {
  84. URL.revokeObjectURL(src)
  85. rej(e)
  86. }
  87. })
  88. const getJSONBody = url => xf.get(url).json(r => r.body)
  89. const getIllustData = id => getJSONBody(`/ajax/illust/${id}`)
  90. const getUgoiraMeta = id => getJSONBody(`/ajax/illust/${id}/ugoira_meta`)
  91. const getCrossOriginBlob = (url, Referer = 'https://www.pixiv.net/') =>
  92. gxf.get(url, { headers: { Referer } }).blob()
  93. const getImageFromPximg = (url, pixivcat_multiple_systax) => {
  94. if (USE_PIXIVCAT) {
  95. const [_, id, idx] = /\/(\d+)_p(\d+)/.exec(url)
  96. const newUrl = pixivcat_multiple_systax
  97. ? `https://pixiv.cat/${id}-${parseInt(idx) + 1}.png`
  98. : `https://pixiv.cat/${id}.png`
  99. return xf.get(newUrl).blob()
  100. }
  101. return getCrossOriginBlob(url)
  102. }
  103. const saveImage = async ({ single, multiple }, id) => {
  104. const illustData = await getIllustData(id)
  105. if (snackbar) {
  106. snackbar.createSnackbar(`Downloading ${illustData.title}...`, {
  107. timeout: 1000
  108. })
  109. }
  110. let results
  111. const { illustType } = illustData
  112. switch (illustType) {
  113. case 0:
  114. case 1:
  115. {
  116. // normal
  117. const url = illustData.urls.original
  118. const ext = url
  119. .split('/')
  120. .pop()
  121. .split('.')
  122. .pop()
  123. if (illustData.pageCount === 1) {
  124. results = [[single(illustData) + '.' + ext, await getImageFromPximg(url)]]
  125. } else {
  126. const len = illustData.pageCount
  127. const ar = []
  128. for (let i = 0; i < len; i++) {
  129. ar.push(
  130. Promise.all([
  131. multiple(illustData, i) + '.' + ext,
  132. getImageFromPximg(url.replace('p0', `p${i}`), true)
  133. ])
  134. )
  135. }
  136. results = await Promise.all(ar)
  137. }
  138. }
  139. break
  140. case 2: {
  141. // ugoira
  142. const fname = single(illustData)
  143. const ugoiraMeta = await getUgoiraMeta(id)
  144. const ugoiraZip = await xf.get(ugoiraMeta.originalSrc).blob()
  145. const { files } = await JSZip.loadAsync(ugoiraZip)
  146. const frames = await Promise.all(Object.values(files).map(f => f.async('blob').then(blobToCanvas)))
  147. if (SAVE_UGOIRA_AS_WEBM) {
  148. const getWebm = (data, frames) =>
  149. new Promise((res, rej) => {
  150. const encoder = new Whammy.Video()
  151. for (let i = 0; i < frames.length; i++) {
  152. encoder.add(frames[i], data.frames[i].delay)
  153. }
  154. encoder.compile(false, res)
  155. })
  156. results = [[fname + '.webm', await getWebm(ugoiraMeta, frames)]]
  157. } else {
  158. const numCpu = navigator.hardwareConcurrency || 4
  159. const getGif = (data, frames) =>
  160. new Promise((res, rej) => {
  161. const gif = new GIF({ workers: numCpu * 4, quality: 10 })
  162. for (let i = 0; i < frames.length; i++) {
  163. gif.addFrame(frames[i], { delay: data.frames[i].delay })
  164. }
  165. gif.on('finished', x => {
  166. res(x)
  167. })
  168. gif.on('error', rej)
  169. gif.render()
  170. })
  171. results = [[fname + '.gif', await getGif(ugoiraMeta, frames)]]
  172. }
  173. }
  174. }
  175.  
  176. // `filenamify` will normalize file names, since some character is not allowed
  177. if (results.length === 1) {
  178. const [f, blob] = results[0]
  179. downloadBlob(blob, filenamify(f))
  180. } else {
  181. const zip = new JSZip()
  182. for (const [f, blob] of results) {
  183. zip.file(filenamify(f), blob)
  184. }
  185. const blob = await zip.generateAsync({ type: 'blob' })
  186. const zipname = single(illustData)
  187. downloadBlob(blob, filenamify(zipname))
  188. }
  189. }
  190.  
  191. // key shortcut
  192. function getSelector() {
  193. const SELECTOR_MAP = {
  194. '/': 'a.work:hover,a._work:hover,.illust-item-root>a:hover',
  195. '/bookmark\\.php': 'a.work:hover',
  196. '/new_illust\\.php': 'a.work:hover',
  197. '/bookmark_new_illust\\.php': 'figure>div>a:hover,.illust-item-root>a:hover',
  198. '/artworks/\\d+': 'div[role=presentation]>a:hover,canvas:hover',
  199. '/ranking\\.php': 'a.work:hover,.illust-item-root>a:hover',
  200. '/search\\.php': 'figure>div>a:hover',
  201. '/member\\.php': '[href^="/artworks"]:hover,.illust-item-root>a:hover'
  202. }
  203. for (const [key, val] of Object.entries(SELECTOR_MAP)) {
  204. const rgx = new RegExp(`^${key}$`)
  205. if (rgx.test(location.pathname)) {
  206. return val
  207. }
  208. }
  209. }
  210. {
  211. addEventListener('keydown', e => {
  212. if (e.which !== KEYCODE_TO_SAVE) return
  213. e.preventDefault()
  214. e.stopPropagation()
  215. const selector = getSelector()
  216. let id
  217. if ($('#Patchouli')) {
  218. const el = $('.image-item-image:hover>a')
  219. if (el) id = /\d+/.exec(el.href.split('/').pop())[0]
  220. }
  221. if (!id && typeof selector === 'string') {
  222. const el = $(selector)
  223. if (el && el.href) id = /\d+/.exec(el.href.split('/').pop())[0]
  224. else if (location.pathname.startsWith('/artwork')) id = location.pathname.split('/').pop()
  225. }
  226. if (id) saveImage(FORMAT, id).catch(console.error)
  227. })
  228. }
  229. {
  230. document.body.appendChild(
  231. $el('link', {
  232. rel: 'stylesheet',
  233. href: 'https://unpkg.com/@snackbar/core@1.7.0/dist/snackbar.min.css'
  234. })
  235. )
  236. }
  237. })()