Greasy Fork 还支持 简体中文。

Fanbox Batch Downloader

Batch Download on creator, not post

目前為 2020-04-29 提交的版本,檢視 最新版本

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