Fanbox Batch Downloader

Batch Download on creator, not post

目前為 2020-05-14 提交的版本,檢視 最新版本

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