Simple Image Saving on Pixiv
This is a user script for browsers that allows you to easily save images on Pixiv.
Download and Source Code
- GreasyFork: 369930-pixiv-easy-save-image
- GitHub: maple3142/browser-extensions pixiv-easy-save-image.user.js
For usage instructions, please refer to the GreasyFork link above. This article mainly introduces the principles behind it.
Features
- Save single images
- Save multiple images as zip
- Save manga as zip
- Save animated images as gif
- Customize file name format
- Save images with just one key press
- Supports multiple pages, including work pages, home page, favorites, search, etc.
- Compatible with Patchouli script
Architecture
Below is the basic execution flow of this script:
Keyboard event -> Get the currently selected image id -> Get image information -> Download the image based on its type
Pressing the keyboard and getting the image id
Code for v0.6.0:
// key shortcut
{
const SELECTOR_MAP = {
'/': 'a.work:hover,a._work:hover,.illust-item-root>a:hover',
'/bookmark.php': 'a.work:hover,.image-item-image>a:hover',
'/new_illust.php': 'a.work:hover,.image-item-image>a:hover',
'/bookmark_new_illust.php': 'figure>div>a:hover,.illust-item-root>a:hover',
'/member_illust.php': 'div[role=presentation]>a:hover,canvas:hover',
'/ranking.php': 'a.work:hover,.illust-item-root>a:hover',
'/search.php': 'figure>div>a:hover',
'/member.php': '[href^="/member_illust.php"]:hover,.illust-item-root>a:hover'
}
const selector = SELECTOR_MAP[location.pathname]
addEventListener('keydown', e => {
if (e.which !== KEYCODE_TO_SAVE) return
e.preventDefault()
e.stopPropagation()
let id
if (!id && $('#Patchouli')) {
const el = $('.image-item-image:hover>a')
if (!el) return
id = /\d+/.exec(el.href.split('/').pop())[0]
} else if (typeof selector === 'string') {
const el = $(selector)
if (!el) return
if (el.href) id = /\d+/.exec(el.href.split('/').pop())[0]
else id = new URLSearchParams(location.search).get('illust_id')
} else {
id = selector()
}
if (id) saveImage(FORMAT, id).catch(console.error)
})
}
It is clear that when it receives the keydown
event, it first checks if it matches the configured key. If not, it is ignored.
Based on the current page, it uses the SELECTOR_MAP
to get a specific selector, which selects the <a>
element in the hover
state and retrieves the image id from its href
. If the user has installed the Patchouli script, it directly uses the selector .image-item-image:hover>a
to select images from its image list.
After getting the id, it directly calls the saveImage
function to save the image.
saveImage Function
Code for v0.6.0:
const saveImage = async ({ single, multiple }, id) => {
const illustData = await getIllustData(id)
let results
const { illustType } = illustData
switch (illustType) {
case 0:
case 1:
{
// normal
const url = illustData.urls.original
const ext = url
.split('/')
.pop()
.split('.')
.pop()
if (illustData.pageCount === 1) {
results = [[single(illustData) + '.' + ext, await getCrossOriginBlob(url)]]
} else {
const len = illustData.pageCount
const ar = []
for (let i = 0; i < len; i++) {
ar.push(
Promise.all([
multiple(illustData, i) + '.' + ext,
getCrossOriginBlob(url.replace('p0', `p${i}`))
])
)
}
results = await Promise.all(ar)
}
}
break
case 2: {
// ugoira
const fname = single(illustData)
const numCpu = navigator.hardwareConcurrency || 4
const gif = new GIF({ workers: numCpu * 4, quality: 10 })
const ugoiraMeta = await getUgoiraMeta(id)
const ugoiraZip = await xf.get(ugoiraMeta.originalSrc).blob()
const { files } = await JSZip.loadAsync(ugoiraZip)
const gifFrames = await Promise.all(Object.values(files).map(f => f.async('blob').then(blobToImg)))
const getGif = (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', x => {
console.timeEnd('gif')
res(x)
})
gif.on('error', rej)
gif.render()
})
results = await [[fname + '.gif', await getGif(ugoiraMeta, gifFrames)]]
}
}
if (results.length === 1) {
const [f, blob] = results[0]
downloadBlob(blob, f)
} else {
const zip = new JSZip()
for (const [f, blob] of results) {
zip.file(f, blob)
}
const blob = await zip.generateAsync({ type: 'blob' })
const zipname = single(illustData)
downloadBlob(blob, zipname)
}
}
Getting Image Information
This part of the code is written as a function and can be seen at L90-L92. It sends a GET /ajax/illust/:id
request to get the image information in json format and categorizes it based on illustType
. 0
and 1
are illustrations and manga, respectively, while 2
represents animated images. For animated images, it sends an additional GET /ajax/illust/:id/ugoira_meta
request to get the information.
Single Image, Multiple Images, and Manga
This is in the case 0:
case 1:
part above.
It first gets the URL and file extension from illustData.urls.original
, then chooses to call FORMAT.single
or FORMAT.multiple
based on the number of images to get the file name, and stores the result in the results
array. The results
array format is roughly Pair<name,blobdata>[]
, and then it chooses to download directly or compress into a zip
and then download based on the number of images.
The zip file is handled using JSZip, which supports compression and decompression.
Animated Images
In the case 2:
part.
It first gets a zip file URL from ugoiraMeta.originalSrc
, then retrieves its content and decompresses it. Next, it uses an additional gif.js to generate a GIF
object, with the number of workers
being four times the number of CPUs.
It then uses the decompressed files frames
and the duration data of each image ugoiraMeta.frames
to call the gif.addFrame
function. Once the image is processed, it directly returns its blobdata and stores it in the results
array. The next steps are the same as above.
getCrossOriginBlob Function
Code for v0.6.0:
const getCrossOriginBlob = (url, Referer = 'https://www.pixiv.net/') => gxf.get(url, { headers: { Referer } }).blob()
You can see that I use this function to fetch images because I need to get the image data, but the image domain is pximg.net
, and directly sending a request would have cross-origin policy issues. Using the script manager’s cross-origin request function GM_xmlhttpRequest
does not include Referer
by default, which would result in pximg.net
returning 403 Forbidden
.
The gxf
is an object created by combining gmxhr-fetch and xfetch-js, which is essentially an HTTP client for making cross-origin HTTP requests.
Postscript
I actually wrote this article because it’s been too long since I posted a new one. I didn’t post new articles before because I was preparing for the university entrance exam. But now that it’s over, it’s really inexcusable not to post…