Fanbox Batch Downloader

Batch Download on creator, not post

当前为 2021-10-26 提交的版本,查看 最新版本

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