Fanbox Batch Downloader

Batch Download on creator, not post

当前为 2020-03-27 提交的版本,查看 最新版本

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