Fanbox Batch Downloader

Batch Download on creator, not post

  1. /* global unsafeWindow dat GM_addStyle */
  2. // ==UserScript==
  3. // @name Fanbox Batch Downloader
  4. // @namespace http://tampermonkey.net/
  5. // @version 0.800.3
  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}`, {
  338. credentials: "include"
  339. })).json()).body.body
  340. if (!p[i].body) continue
  341. } catch (e) {
  342. console.error(e)
  343. continue
  344. }
  345.  
  346. if (p[i].coverImageUrl) {
  347. gmRequireImage(p[i].coverImageUrl).then(blob => {
  348. zip.add(folder, `cover${p[i].coverImageUrl.slice(p[i].coverImageUrl.lastIndexOf('.'))}`, blob)
  349. }).catch(e => {
  350. console.error(`Failed to download: ${p[i].coverImageUrl}\n${e}`)
  351. })
  352. }
  353. let { blocks, embedMap, imageMap, fileMap, files, images, text } = p[i].body
  354. let picIndex = 0
  355. let fileIndex = 0
  356. let imageList = []
  357. let fileList = []
  358.  
  359. if (blocks?.length > 0) {
  360. let article = `# ${p[i].title}\n`
  361. for (let j = 0; j < blocks.length; ++j) {
  362. switch (blocks[j].type) {
  363. case 'p': {
  364. article += `${blocks[j].text}\n\n`
  365. break
  366. }
  367. case 'image': {
  368. let image = imageMap[blocks[j].imageId]
  369. imageList.push(image)
  370. article += `![${p[i].title} - P${picIndex}](${folder}_${picIndex}.${image.extension})\n\n`
  371. picIndex++
  372. break
  373. }
  374. case 'file': {
  375. let file = fileMap[blocks[j].fileId]
  376. fileList.push(file)
  377. article += `[File${fileIndex} - ${file.name}](${file.name}.${file.extension})\n\n`
  378. fileIndex++
  379. break
  380. }
  381. case 'embed': {
  382. let extenalUrl = embedMap[blocks[j].embedId]
  383. let serviceProvideMap = {
  384. gist: `[Github Gist - ${extenalUrl.contentId}](https://gist.github.com/${extenalUrl.contentId})`,
  385. google_forms: `[Google Forms - ${extenalUrl.contentId}](https://docs.google.com/forms/d/e/${extenalUrl.contentId}/viewform)`,
  386. soundcloud : `[SoundCloud - ${extenalUrl.contentId}](https://soundcloud.com/${extenalUrl.contentId})`,
  387. twitter: `[Twitter - ${extenalUrl.contentId}](https://twitter.com/i/web/status/${extenalUrl.contentId})`,
  388. vimeo : `[Vimeo - ${extenalUrl.contentId}](https://vimeo.com/${extenalUrl.contentId})`,
  389. youtube: `[Youtube - ${extenalUrl.contentId}](https://www.youtube.com/watch?v=${extenalUrl.contentId})`
  390. }
  391. article += serviceProvideMap[extenalUrl.serviceProvider] + '\n\n'
  392. break
  393. }
  394. }
  395. }
  396.  
  397. zip.add(folder, 'article.md', new Blob([article]))
  398. for (let j = 0; j < imageList.length; ++j) {
  399. let image = imageList[j]
  400. let index = amount
  401. amount++
  402. pool.add(() => new Promise((resolve, reject) => {
  403. gmRequireImage(image.originalUrl, index).then(blob => {
  404. processed++
  405. zip.add(folder, `${folder}_${j}.${image.extension}`, blob)
  406. stepped++
  407. resolve()
  408. }).catch(() => {
  409. console.log(`Failed to download: ${image.originalUrl}`)
  410. reject()
  411. })
  412. }))
  413. }
  414. for (let j = 0; j < fileList.length; ++j) {
  415. let file = fileList[j]
  416. let index = amount
  417. amount++
  418. pool.add(() => new Promise((resolve, reject) => {
  419. gmRequireImage(file.url, index).then(blob => {
  420. processed++
  421. zip.add(folder, `${file.name}.${file.extension}`, blob)
  422. stepped++
  423. resolve()
  424. }).catch(() => {
  425. console.log(`Failed to download: ${file.url}`)
  426. reject()
  427. })
  428. }))
  429. }
  430. }
  431.  
  432. if (files) {
  433. for (let j = 0; j < files.length; ++j) {
  434. let file = files[j]
  435. let index = amount
  436. amount++
  437. pool.add(() => new Promise((resolve, reject) => {
  438. gmRequireImage(file.url, index).then(blob => {
  439. processed++
  440. let fileIndexText = ''
  441. if (files.length > 1) fileIndexText = `-${j}`
  442. if (blob.size < 600 * 1024 * 1024)
  443. zip.add(folder, `${file.name}${fileIndexText}.${file.extension}`, blob)
  444. else
  445. saveBlob(blob, `${creatorInfo.name}@${folder}${fileIndexText}.${file.extension}`)
  446. stepped++
  447. resolve()
  448. }).catch(() => {
  449. console.log(`Failed to download: ${file.url}`)
  450. reject()
  451. })
  452. }))
  453. }
  454. }
  455. if (images) {
  456. for (let j = 0; j < images.length; ++j) {
  457. let image = images[j]
  458. let index = amount
  459. amount++
  460. pool.add(() => new Promise((resolve, reject) => {
  461. gmRequireImage(image.originalUrl, index).then(blob => {
  462. processed++
  463. zip.add(folder, `${folder}_${j}.${image.extension}`, blob)
  464. stepped++
  465. resolve()
  466. }).catch(() => {
  467. console.log(`Failed to download: ${image.url}`)
  468. reject()
  469. })
  470. }))
  471. }
  472. }
  473.  
  474. if (text) {
  475. let textBlob = new Blob([text], { type: 'text/plain' })
  476. zip.add(folder, `${creatorInfo.name}-${folder}.txt`, textBlob)
  477. }
  478. }
  479.  
  480. if (creatorInfo.cover)
  481. gmRequireImage(creatorInfo.cover, 0).then(blob => {
  482. zip.file('cover.jpg', blob)
  483. if (amount === 0) zip.pack()
  484. })
  485. }
  486.  
  487. async function getAllPostsByFanboxId(creatorId) {
  488. // request userinfo
  489. const userUri = `${apiUserUri}?creatorId=${creatorId}`
  490. const userData = await (await fetch(userUri, fetchOptions)).json()
  491. let creatorInfo = {
  492. cover: null,
  493. posts: []
  494. }
  495. const limit = 56
  496. creatorInfo.cover = userData.body.coverImageUrl
  497. creatorInfo.name = userData.body.user.name
  498.  
  499. // request post info
  500. let postData = await (await fetch(`${apiPostListUri}?creatorId=${creatorId}&limit=${limit}`, fetchOptions)).json()
  501. creatorInfo.posts.push(...postData.body.items)
  502. let nextPageUrl = postData.body.nextUrl
  503. while (nextPageUrl) {
  504. let nextData = await (await fetch(nextPageUrl, fetchOptions)).json()
  505. creatorInfo.posts.push(...nextData.body.items)
  506. nextPageUrl = nextData.body.nextUrl
  507. }
  508. console.log(creatorInfo)
  509. return creatorInfo
  510. }
  511.  
  512. function saveBlob(blob, fileName) {
  513. let downloadDom = document.createElement('a')
  514. document.body.appendChild(downloadDom)
  515. downloadDom.style = `display: none`
  516. let url = window.URL.createObjectURL(blob)
  517. downloadDom.href = url
  518. downloadDom.download = fileName
  519. downloadDom.click()
  520. window.URL.revokeObjectURL(url)
  521. }
  522.  
  523. function gmRequireImage(url, index) {
  524. let total = 0;
  525. return new Promise((resolve, reject) =>
  526. GM_xmlhttpRequest({
  527. method: 'GET',
  528. url,
  529. overrideMimeType: 'application/octet-stream',
  530. responseType: 'blob',
  531. asynchrouns: true,
  532. credentials: "include",
  533. onload: res => {
  534. if (index !== undefined) {
  535. progressList[index] = 1
  536. setProgress(amount)
  537. }
  538. resolve(res.response)
  539. },
  540. onprogress: res => {
  541. total = Math.max(total, res.total)
  542. index !== undefined && (progressList[index] = res.done / res.total)
  543. setProgress(amount)
  544. },
  545. onerror: () =>
  546. GM_xmlhttpRequest({
  547. method: 'GET',
  548. url,
  549. overrideMimeType: 'application/octet-stream',
  550. responseType: 'arraybuffer',
  551. onload: res => {
  552. if (index !== undefined) {
  553. progressList[index] = 1
  554. setProgress(amount)
  555. }
  556. resolve(new Blob([res.response]))
  557. },
  558. onprogress: res => {
  559. if (index !== undefined) {
  560. progressList[index] = res.done / res.total
  561. setProgress(amount)
  562. }
  563. },
  564. onerror: reject
  565. })
  566. })
  567. )
  568. }
  569.  
  570. function purifyName(filename) {
  571. return filename.replaceAll(':', '').replaceAll('/', '').replaceAll('\\', '').replaceAll('>', '').replaceAll('<', '')
  572. .replaceAll('*:', '').replaceAll('|', '').replaceAll('?', '').replaceAll('"', '')
  573. }
  574. })()

QingJ © 2025

镜像随时可能失效,请加Q群300939539或关注我们的公众号极客氢云获取最新地址