Simple Image Saving on Pixiv

發表於
分類於 userscript
This article is LLM-translated by GPT-4o, so the translation may be inaccurate or complete. If you find any mistake, please let me know.

This is a user script for browsers that allows you to easily save images on Pixiv.

Download and Source Code

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…