Pixiv 簡單存圖

這是一個在瀏覽器使用的使用者腳本,可以讓你簡單的在 Pixiv 上面存圖

載點與原始碼

使用教學請點進上方的 GreasyFork 中閱讀,本文主要以介紹原理為主

功能

  • 存單圖
  • 存多圖為 zip
  • 存漫畫為 zip
  • 存動圖為 gif
  • 自訂檔案名稱格式
  • 存圖只需按一個鍵
  • 支援多頁面,包括作品頁、首頁、收藏、搜尋等等...
  • Patchouli 腳本相容

架構

以下是本腳本的基本執行流程:

按下鍵盤的事件 -> 取得目前選取的圖片 id -> 取得圖片資訊 -> 根據種類個別下載圖片

按下鍵盤並取得圖片 id

v0.6.0 的程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
// 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)
})
}

其中可以很明顯的知道它在收到keydown的事件時先檢查是否與設定好的按鍵相符,不符則直接忽略

下方會根據目前的頁面從上面的SELECTOR_MAP取得特定得選擇器,而該選擇器都會選到正在hover狀態的<a>元素,並從其href中取得圖片的 id 而若是使用者有安裝 Patchouli 腳本,則會直接使用選擇器.image-item-image:hover>a從它的圖片列表中選取圖片

在取得 id 後會直接呼叫saveImage函數去儲存圖片

saveImage 函數

v0.6.0 的程式碼:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
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)
}
}

取得圖片資訊

這部分的程式碼被我寫成函數了,可以在 L90-L92 看到 它會發送GET /ajax/illust/:id形式的請求取得 json 格式的圖片資訊,並依照其中的illustType分類,01分別是插畫與漫畫,而2代表的是動圖 動圖方面則另外發送GET /ajax/illust/:id/ugoira_meta的請求取得資訊

單圖、多圖與漫畫

這在上方的case 0: case 1:的部分

先從illustData.urls.original取得網址與副檔名,然後依照圖片數量選擇要呼叫FORMAT.single還是FORMAT.multiple得到檔案名稱,並將結果存到results陣列裡 results陣列的格式大略為Pair<name,blobdata>[],然後在下方依照其數量選擇直接下載或壓縮為zip再下載

壓縮檔是使用 JSZip 處裡的,支援壓縮和解壓縮

動圖

case 2:的部分

這會先從ugoiraMeta.originalSrc取得一個壓縮檔的網址,然後取得其內容並將它解壓縮 再來會用額外的 gif.js 產生一個GIF的物件,其workers數量是 CPU 數量的四倍

再來後面會利用到解壓縮出來的檔案frames和各張圖所持續的時間資料ugoiraMeta.frames去呼叫gif.addFrame函數 當圖片處理好之後就直接回傳它的 blobdata 存到 results 陣列裡面,接下來做的事就和上面一樣了

getCrossOriginBlob 函數

v0.6.0 的程式碼:

1
const getCrossOriginBlob = (url, Referer = 'https://www.pixiv.net/') => gxf.get(url, { headers: { Referer } }).blob()

其實可以發現我在抓圖片時會使用這個函數,原因是我需要取得圖片資料,但圖片的網域在pximg.net,直接發送請求會有同源政策的問題 而使用腳本管理器的跨域請求函數GM_xmlhttpRequest預設不會帶Referer,這又會導致pximg.net回傳403 Forbidden

其中的gxf是結合 gmxhr-fetchxfetch-js 所產生出的物件,基本上就是一個跨域發 http 請求的 http client

後記

其實我發這篇文章是因為覺得太久發新文章了,之前不發新聞章是為了大學學測。但現在已經考完了,再不發真的說不過去...