Fanbox Batch Downloader

Batch Download on creator, not post

目前為 2020-02-24 提交的版本,檢視 最新版本

  1. // ==UserScript==
  2. // @name Fanbox Batch Downloader
  3. // @namespace http://tampermonkey.net/
  4. // @version 0.60
  5. // @description Batch Download on creator, not post
  6. // @author https://github.com/amarillys
  7. // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.2.2/jszip.min.js
  8. // @require https://cdnjs.cloudflare.com/ajax/libs/dat-gui/0.7.6/dat.gui.min.js
  9. // @match https://www.pixiv.net/fanbox/creator/*
  10. // @grant GM_xmlhttpRequest
  11. // @grant GM_addStyle
  12. // @grant unsafeWindow
  13. // @run-at document-end
  14. // @license MIT
  15. // ==/UserScript==
  16.  
  17. /**
  18. * Update Log
  19. * > 200224
  20. * More beautiful! UI Redesigned. --use dat.gui,
  21. * Performence Improved. -- multi-thread supported.
  22. * > 200222
  23. * Bug Fixed - Psd files download failure <Change download type from blob to arraybuffer, which cause low performence>
  24. * Bug Fixed - Display incorrect on partial download
  25. * > 200222
  26. * Bug Fixed - Post with '/' cause deep path in zip
  27. * > 200102
  28. * Bug Fixed - Caused by empty cover
  29. * > 191228
  30. * Bug Fixed
  31. * Correct filenames
  32. * > 191227
  33. * Code Reconstruct
  34. * Support downloading of artice
  35. * Correct filenames
  36. *
  37. * // 中文注释
  38. * 代码重构
  39. * 新增对文章的下载支持
  40. * > 200222
  41. * 偷懒,以后不加中文注释
  42. * > 191226
  43. * Support downloading by batch(default: 100 files per batch)
  44. * Support donwloading by specific index
  45. * // 中文注释
  46. * 新增支持分批下载的功能(默认100个文件一个批次)
  47. * 新增支持按索引下载的功能
  48. *
  49. * > 191223
  50. * Add support of files
  51. * Improve the detect of file extension
  52. * Change Download Request as await, for avoiding delaying.
  53. * Add manual package while click button use middle button of mouse
  54. * // 中文注释
  55. * 增加对附件下载的支持
  56. * 优化文件后缀名识别
  57. * 修改下载方式为按顺序下载,避免超时
  58. * 增加当鼠标中键点击时手动打包
  59. **/
  60.  
  61. /* global JSZip GM_xmlhttpRequest */
  62. ;(function() {
  63. 'use strict'
  64.  
  65. // set style
  66. GM_addStyle(`
  67. .dg.main{
  68. top: 16px;
  69. position: fixed;
  70. left: 20%;
  71. filter: drop-shadow(2px 4px 6px black);
  72. opacity: 0.8;
  73. z-index: 999;
  74. }
  75. li.cr.number.has-slider:nth-child(2) {
  76. pointer-events: none;
  77. }
  78. .slider-fg {
  79. transition: width 0.8s ease-out;
  80. }
  81. `)
  82.  
  83. window = unsafeWindow
  84. class ThreadPool {
  85. constructor(poolSize) {
  86. this.size = poolSize || 20
  87. this.running = 0
  88. this.waittingTasks = []
  89. this.callback = []
  90. this.tasks = []
  91. this.counter = 0
  92. this.sum = 0
  93. this.finished = false
  94. this.errorLog = ''
  95. this.step = () => {}
  96. this.timer = null
  97. this.callback.push(() =>
  98. console.log(this.errorLog)
  99. )
  100. }
  101.  
  102. status() {
  103. return ((this.counter / this.sum) * 100).toFixed(1) + '%'
  104. }
  105.  
  106. run() {
  107. if (this.finished) return
  108. if (this.waittingTasks.length === 0)
  109. if (this.running <= 0) {
  110. for (let m = 0; m < this.callback.length; ++m)
  111. this.callback[m] && this.callback[m]()
  112. this.finished = true
  113. } else return
  114.  
  115. while (this.running < this.size) {
  116. if (this.waittingTasks.length === 0) return
  117. let curTask = this.waittingTasks[0]
  118. curTask.do().then(
  119. onSucceed => {
  120. this.running--
  121. this.counter++
  122. this.step()
  123. this.run()
  124. typeof onSucceed === 'function' && onSucceed()
  125. },
  126. onFailed => {
  127. this.errorLog += onFailed + '\n'
  128. this.running--
  129. this.counter++
  130. this.step()
  131. this.run()
  132. curTask.err()
  133. }
  134. )
  135. this.waittingTasks.splice(0, 1)
  136. this.tasks.push(this.waittingTasks[0])
  137. this.running++
  138. }
  139. }
  140.  
  141. add(fn, errFn) {
  142. this.waittingTasks.push({ do: fn, err: errFn || (() => {}) })
  143. this.sum++
  144. clearTimeout(this.timer)
  145. this.timer = setTimeout(() => {
  146. this.run()
  147. clearTimeout(this.timer)
  148. }, this.autoStartTime)
  149. }
  150.  
  151. setAutoStart(time) {
  152. this.autoStartTime = time
  153. }
  154.  
  155. finish(callback) {
  156. this.callback.push(callback)
  157. }
  158.  
  159. isFinished() {
  160. return this.finished
  161. }
  162. }
  163.  
  164. class Zip {
  165. constructor(title) {
  166. this.title = title
  167. this.zip = new JSZip()
  168. this.size = 0
  169. this.partIndex = 0
  170. }
  171. file(filename, blob) {
  172. this.zip.file(filename, blob, {
  173. compression: 'STORE'
  174. })
  175. this.size += blob.size
  176. }
  177. add(folder, name, blob) {
  178. if (this.size + blob.size >= Zip.MAX_SIZE) {
  179. let index = this.partIndex
  180. this.zip
  181. .generateAsync({
  182. type: 'blob'
  183. })
  184. .then(zipBlob => saveBlob(zipBlob, `${this.title}-${index}.zip`))
  185. this.partIndex++
  186. this.zip = new JSZip()
  187. this.size = 0
  188. }
  189. this.zip.folder(folder).file(name, blob, {
  190. compression: 'STORE'
  191. })
  192. this.size += blob.size
  193. }
  194. pack() {
  195. if (this.size === 0) return
  196. let index = this.partIndex
  197. this.zip
  198. .generateAsync({
  199. type: 'blob'
  200. })
  201. .then(zipBlob => saveBlob(zipBlob, `${this.title}-${index}.zip`))
  202. this.partIndex++
  203. this.zip = new JSZip()
  204. this.size = 0
  205. }
  206. }
  207. Zip.MAX_SIZE = 1048576000
  208.  
  209. const creatorId = parseInt(document.URL.split('/')[5])
  210. let creatorInfo = null
  211. let options = {
  212. start: 1,
  213. end: 1,
  214. thread: 6,
  215. batch: 200,
  216. progress: 0,
  217. speed: 0
  218. }
  219.  
  220. const Text = {
  221. batch: '分批 / Batch',
  222. download: '点击这里下载',
  223. download_en: 'Click to Download',
  224. init: '初始化中...',
  225. init_en: 'Initilizing...',
  226. initFinished: '初始化完成',
  227. initFinished_en: 'Initilized',
  228. start: '起始 / start',
  229. end: '结束 / end',
  230. thread: '线程 / threads',
  231. pack: '手动打包(不推荐)',
  232. pack_en: 'manual pack(Not Rcm)',
  233. progress: '进度 / Progress',
  234. speed: '网速 / speed'
  235. }
  236.  
  237. const clickHandler = {
  238. init() {},
  239. download: () => {
  240. console.log('startDownloading')
  241. downloadByFanboxId(creatorInfo, creatorId)
  242. },
  243. pack() {
  244. zip.pack()
  245. }
  246. }
  247. const EN_FIX = navigator.language.indexOf('zh') > -1 ? '' : '_en'
  248.  
  249. const gui = new dat.GUI({
  250. autoPlace: false,
  251. useLocalStorage: false
  252. })
  253. let initText = gui.add(clickHandler, 'init').name(Text['init' + EN_FIX])
  254. let progressCtl = null
  255.  
  256. let init = async () => {
  257. let base = unsafeWindow.document.querySelector('#root')
  258.  
  259. base.appendChild(gui.domElement)
  260. uiInited = true
  261.  
  262. creatorInfo = await getAllPostsByFanboxId(creatorId)
  263. initText.name(Text['initFinished' + EN_FIX])
  264.  
  265. // init dat gui
  266. const sum = creatorInfo.posts.length
  267. progressCtl = gui.add(options, 'progress', 0, 100, 1).name(Text['progress'])
  268. const startCtl = gui.add(options, 'start', 1, sum, 1).name(Text['start'])
  269. const endCtl = gui.add(options, 'end', 1, sum, 1).name(Text['end'])
  270. endCtl.setValue(sum)
  271. gui.add(options, 'thread', 1, 20, 1).name(Text['thread'])
  272. gui.add(options, 'batch', 10, 5000, 10).name(Text['batch'])
  273. gui.add(clickHandler, 'download').name(Text['download' + EN_FIX])
  274. gui.add(clickHandler, 'pack').name(Text['pack' + EN_FIX])
  275.  
  276. startCtl.onChange(() => {
  277. if (options.start > options.end)
  278. options.start = options.end
  279. })
  280. endCtl.onChange(() => {
  281. if (options.end < options.start)
  282. options.end = options.start
  283. })
  284.  
  285. gui.open()
  286. }
  287.  
  288. // init global values
  289. let zip = null
  290. let amount = 0
  291. let pool = null
  292. let uiInited = false
  293. const fetchOptions = {
  294. credentials: 'include',
  295. headers: {
  296. Accept: 'application/json, text/plain, */*'
  297. }
  298. }
  299. window.onload = () => {
  300. init()
  301. let timer = setInterval(() => {
  302. if (!uiInited && document.querySelector('.dg.main') === null)
  303. init()
  304. else clearInterval(timer)
  305. }, 3000)
  306. }
  307.  
  308. function gmRequireImage(url) {
  309. return new Promise((resolve, reject) =>
  310. GM_xmlhttpRequest({
  311. method: 'GET',
  312. url,
  313. overrideMimeType: 'application/octet-stream',
  314. responseType: 'blob',
  315. asynchrouns: true,
  316. onload: res => resolve(res.response),
  317. onprogress: res => console.log(`${res.done} / ${res.total}, ${new Date().getTime()}`),
  318. onerror: () =>
  319. GM_xmlhttpRequest({
  320. method: 'GET',
  321. url,
  322. overrideMimeType: 'application/octet-stream',
  323. responseType: 'arraybuffer',
  324. onload: res => resolve(new Blob([res.response])),
  325. onerror: res => reject(res)
  326. })
  327. })
  328. )
  329. }
  330.  
  331. async function downloadByFanboxId(creatorInfo, creatorId) {
  332. let processed = 0
  333. gui.remove(initText)
  334. progressCtl.setValue(0)
  335. let { batch, end, start, thread } = options
  336. options.progress = 0
  337. zip = new Zip(`${creatorId}-${creatorInfo.name}-${start + 1}-${end}`)
  338. let stepped = 0
  339. if (creatorInfo.cover)
  340. zip.file('cover.jpg', await gmRequireImage(creatorInfo.cover))
  341.  
  342. // init pool
  343. pool = new ThreadPool(thread)
  344. pool.finish(() => {
  345. zip.pack()
  346. })
  347.  
  348. // start downloading
  349. for (let i = start, p = creatorInfo.posts; i < end; ++i) {
  350. let folder = `${p[i].title.replace(/\//g, '-')}-${p[i].id}`
  351. if (!p[i].body) continue
  352. let { blocks, imageMap, fileMap, files, images } = p[i].body
  353. let picIndex = 0
  354. let imageList = []
  355. let fileList = []
  356.  
  357. if (p[i].type === 'article') {
  358. let article = `# ${p[i].title}\n`
  359. for (let j = 0; j < blocks.length; ++j) {
  360. switch (blocks[j].type) {
  361. case 'p': {
  362. article += `${blocks[j].text}\n\n`
  363. break
  364. }
  365. case 'image': {
  366. picIndex++
  367. let image = imageMap[blocks[j].imageId]
  368. imageList.push(image)
  369. article += `![${p[i].title} - P${picIndex}](${folder}_${j}.${image.extension})\n\n`
  370. break
  371. }
  372. case 'file': {
  373. let file = fileMap[blocks[j].fileId]
  374. fileList.push(file)
  375. article += `[${p[i].title} - ${file.name}](${creatorId}-${folder}-${file.name}.${file.extension})\n\n`
  376. break
  377. }
  378. }
  379. }
  380.  
  381. zip.add(folder, 'article.md', new Blob([article]))
  382. for (let j = 0; j < imageList.length; ++j) {
  383. let image = imageList[j]
  384. amount++
  385. pool.add(() => new Promise((resolve, reject) => {
  386. gmRequireImage(image.originalUrl).then(blob => {
  387. processed++
  388. zip.add(folder, `${folder}_${j}.${image.extension}`, blob)
  389. stepped++
  390. resolve()
  391. }).catch(() => {
  392. console.log(`Failed to download: ${image.originalUrl}`)
  393. reject()
  394. })
  395. }))
  396. }
  397. for (let j = 0; j < fileList.length; ++j) {
  398. let file = fileList[j]
  399. amount++
  400. pool.add(() => new Promise((resolve, reject) => {
  401. gmRequireImage(file.url).then(blob => {
  402. processed++
  403. saveBlob(blob, `${creatorId}-${folder}_${j}-${file.name}.${file.extension}`)
  404. stepped++
  405. resolve()
  406. }).catch(() => {
  407. console.log(`Failed to download: ${file.url}`)
  408. reject()
  409. })
  410. }))
  411. }
  412. }
  413.  
  414. if (files) {
  415. for (let j = 0; j < files.length; ++j) {
  416. let file = files[j]
  417. amount++
  418. pool.add(() => new Promise((resolve, reject) => {
  419. gmRequireImage(file.url).then(blob => {
  420. processed++
  421. if (blob.size < 51200000)
  422. zip.add(folder, `${file.name}.${file.extension}`)
  423. else
  424. saveBlob(blob, `${folder}-${creatorInfo.name}-${folder}_${j}.${file.extension}`)
  425. stepped++
  426. resolve()
  427. }).catch(() => {
  428. console.log(`Failed to download: ${file.url}`)
  429. reject()
  430. })
  431. }))
  432. }
  433. }
  434. if (images) {
  435. for (let j = 0; j < images.length; ++j) {
  436. let image = images[j]
  437. amount++
  438. pool.add(() => new Promise((resolve, reject) => {
  439. gmRequireImage(image.originalUrl).then(blob => {
  440. processed++
  441. zip.add(folder, `${folder}_${j}.${image.extension}`, blob)
  442. stepped++
  443. resolve()
  444. }).catch(() => {
  445. console.log(`Failed to download: ${image.url}`)
  446. reject()
  447. })
  448. }))
  449. }
  450. }
  451. }
  452. pool.step = () => {
  453. progressCtl.setValue(processed / amount * 100)
  454. console.log(` Progress: ${processed} / ${amount}, Pool: ${pool.running} @ ${pool.sum}`)
  455. if (stepped >= batch) {
  456. zip.pack()
  457. stepped = 0
  458. }
  459. }
  460. }
  461.  
  462. async function getAllPostsByFanboxId(creatorId) {
  463. let fristUrl = `https://www.pixiv.net/ajax/fanbox/creator?userId=${creatorId}`
  464. let creatorInfo = {
  465. cover: null,
  466. posts: []
  467. }
  468. let firstData = await (await fetch(fristUrl, fetchOptions)).json()
  469. let body = firstData.body
  470. creatorInfo.cover = body.creator.coverImageUrl
  471. creatorInfo.name = body.creator.user.name
  472. creatorInfo.posts.push(...body.post.items.filter(p => p.body))
  473. let nextPageUrl = body.post.nextUrl
  474. while (nextPageUrl) {
  475. let nextData = await (await fetch(nextPageUrl, fetchOptions)).json()
  476. creatorInfo.posts.push(...nextData.body.items.filter(p => p.body))
  477. nextPageUrl = nextData.body.nextUrl
  478. }
  479. return creatorInfo
  480. }
  481.  
  482. function saveBlob(blob, fileName) {
  483. let downloadDom = document.createElement('a')
  484. document.body.appendChild(downloadDom)
  485. downloadDom.style = `display: none`
  486. let url = window.URL.createObjectURL(blob)
  487. downloadDom.href = url
  488. downloadDom.download = fileName
  489. downloadDom.click()
  490. window.URL.revokeObjectURL(url)
  491. }
  492. })()