Fanbox Batch Downloader

Batch Download on creator, not post

当前为 2022-03-12 提交的版本,查看 最新版本

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