Fanbox Batch Downloader

Batch Download on creator, not post

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, Greasemonkey alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey, % alebo Violentmonkey.

Na nainštalovanie skriptu si budete musieť nainštalovať rozšírenie, ako napríklad Tampermonkey alebo Userscripts.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie, ako napríklad Tampermonkey.

Na inštaláciu tohto skriptu je potrebné nainštalovať rozšírenie správcu používateľských skriptov.

(Už mám správcu používateľských skriptov, nechajte ma ho nainštalovať!)

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie, ako napríklad Stylus.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

Na inštaláciu tohto štýlu je potrebné nainštalovať rozšírenie správcu používateľských štýlov.

(Už mám správcu používateľských štýlov, nechajte ma ho nainštalovať!)

/* global unsafeWindow dat GM_addStyle */
// ==UserScript==
// @name         Fanbox Batch Downloader
// @namespace    http://tampermonkey.net/
// @version      0.800.3
// @description  Batch Download on creator, not post
// @author       https://github.com/amarillys QQ 719862760
// @require      https://cdnjs.cloudflare.com/ajax/libs/jszip/3.2.2/jszip.min.js
// @require      https://cdnjs.cloudflare.com/ajax/libs/dat-gui/0.7.6/dat.gui.min.js
// @match        https://*.fanbox.cc/*
// @match        https://www.fanbox.cc/*
// @grant        GM_xmlhttpRequest
// @grant        GM_addStyle
// @grant        unsafeWindow
// @run-at       document-end
// @license      MIT
// ==/UserScript==


/* global JSZip GM_xmlhttpRequest */
;(function() {
  'use strict'

  const apiUserUri = 'https://api.fanbox.cc/creator.get'
  const apiPostListUri = 'https://api.fanbox.cc/post.listCreator'
  const apiPostUri = 'https://api.fanbox.cc/post.info'
  // set style
  GM_addStyle(`
    .dg.main{
      top: 16px;
      position: fixed;
      left: 20%;
      filter: drop-shadow(2px 4px 6px black);
      opacity: 0.8;
      z-index: 999;
    }
    li.cr.number.has-slider:nth-child(2) {
      pointer-events: none;
    }
    .slider-fg {
      transition: width 0.5s ease-out;
    }
  `)

  window = unsafeWindow
  class ThreadPool {
    constructor(poolSize) {
      this.size = poolSize || 20
      this.running = 0
      this.waittingTasks = []
      this.callback = []
      this.tasks = []
      this.counter = 0
      this.sum = 0
      this.finished = false
      this.errorLog = ''
      this.step = () => {}
      this.timer = null
      this.callback.push(() =>
        console.log(this.errorLog)
      )
    }

    status() {
      return ((this.counter / this.sum) * 100).toFixed(1) + '%'
    }

    run() {
      if (this.finished) return
      if (this.waittingTasks.length === 0)
        if (this.running <= 0) {
          for (let m = 0; m < this.callback.length; ++m)
            this.callback[m] && this.callback[m]()
          this.finished = true
        } else return

      while (this.running < this.size) {
        if (this.waittingTasks.length === 0) return
        let curTask = this.waittingTasks[0]
        curTask.do().then(
          onSucceed => {
            this.running--
            this.counter++
            this.step()
            this.run()
            typeof onSucceed === 'function' && onSucceed()
          },
          onFailed => {
            this.errorLog += onFailed + '\n'
            this.running--
            this.counter++
            this.step()
            this.run()
            curTask.err()
          }
        )
        this.waittingTasks.splice(0, 1)
        this.tasks.push(this.waittingTasks[0])
        this.running++
      }
    }

    add(fn, errFn) {
      this.waittingTasks.push({ do: fn, err: errFn || (() => {}) })
      this.sum++
      clearTimeout(this.timer)
      this.timer = setTimeout(() => {
        this.run()
        clearTimeout(this.timer)
      }, this.autoStartTime)
    }

    setAutoStart(time) {
      this.autoStartTime = time
    }

    finish(callback) {
      this.callback.push(callback)
    }

    isFinished() {
      return this.finished
    }
  }

  class Zip {
    constructor(title) {
      this.title = title
      this.zip = new JSZip()
      this.size = 0
      this.partIndex = 0
    }
    file(filename, blob) {
      this.zip.file(filename, blob, {
        compression: 'STORE'
      })
      this.size += blob.size
    }
    add(folder, name, blob) {
      if (this.size + blob.size >= Zip.MAX_SIZE)
        this.pack()
      this.zip.folder(purifyName(folder)).file(purifyName(name), blob, {
        compression: 'STORE'
      })
      this.size += blob.size
    }
    pack() {
      if (this.size === 0) return
      let index = this.partIndex
      this.zip
        .generateAsync({
          type: 'blob',
          compression: 'STORE'
        })
        .then(zipBlob => saveBlob(zipBlob, `${this.title}-${index}.zip`))
      this.partIndex++
      this.zip = new JSZip()
      this.size = 0
    }
  }
  Zip.MAX_SIZE = 850000000/*1048576000*/

  const creatorId = document.URL.startsWith('https://www') ?
        document.URL.match(/@([\w_-]+)\/?/)?.[1] : document.URL.match(/https:\/\/(.+).fanbox/)?.[1]
  if (!creatorId) return;
  let creatorInfo = null
  let options = {
    start: 1,
    end: 1,
    thread: 6,
    batch: 200,
    progress: 0,
    speed: 0,
    nameWithId: 0,
    nameWithDate: 1,
    nameWithTitle: 1
  }

  const Text = {
    batch: '分批 / Batch',
    download: '点击这里下载',
    download_en: 'Click to Download',
    downloading: '下载中...',
    downloading_en: 'Downloading...',
    packing: '打包中...',
    packing_en: 'Packing...',
    packed: '打包完成',
    packed_en: 'Packed!',
    init: '初始化中...',
    init_en: 'Initilizing...',
    initFailed: '请求数据失败',
    initFailed_en: 'Failed to get Data',
    initFailed_0: '请检查网络',
    initFailed_0_en: 'check network',
    initFailed_1: '或Github联系作者',
    initFailed_1_en: 'or connect at Github',
    initFinished: '初始化完成',
    initFinished_en: 'Initilized',
    name_with_id: '文件名带ID',
    name_with_id_en: 'name with id',
    name_with_date: '文件名带日期',
    name_with_date_en: 'name with date',
    name_with_title: '文件名带名字',
    name_with_title_en: 'name with title',
    start: '起始 / start',
    end: '结束 / end',
    thread: '线程 / threads',
    pack: '手动打包(不推荐)',
    pack_en: 'manual pack(Not Rcm)',
    progress: '进度 / Progress',
    speed: '网速 / speed'
  }
  const EN_FIX = navigator.language.indexOf('zh') > -1 ? '' : '_en'

  let label = null
  const gui = new dat.GUI({
    autoPlace: false,
    useLocalStorage: false
  })

  const clickHandler = {
    text() {},
    download: () => {
      console.log('startDownloading')
      downloadByFanboxId(creatorInfo, creatorId)
    },
    pack() {
      label.name(Text['packing' + EN_FIX])
      zip.pack()
      label.name(Text['packed' + EN_FIX])
    }
  }

  label = gui.add(clickHandler, 'text').name(Text['init' + EN_FIX])
  let progressCtl = null

  let init = async () => {
    let base = window.document.querySelector('#root')

    base.appendChild(gui.domElement)
    uiInited = true

    try {
      creatorInfo = await getAllPostsByFanboxId(creatorId)
      label.name(Text['initFinished' + EN_FIX])
    } catch (e) {
        label.name(Text['initFailed' + EN_FIX])
        gui.add(clickHandler, 'text').name(Text['initFailed_0' + EN_FIX])
        gui.add(clickHandler, 'text').name(Text['initFailed_1' + EN_FIX])
        return
    }

    // init dat gui
    const sum = creatorInfo.posts.length
    progressCtl = gui.add(options, 'progress', 0, 100, 0.01).name(Text.progress)
    const startCtl = gui.add(options, 'start', 1, sum, 1).name(Text.start)
    const endCtl = gui.add(options, 'end', 1, sum, 1).name(Text.end)
    gui.add(options, 'thread', 1, 20, 1).name(Text.thread)
    gui.add(options, 'batch', 10, 5000, 10).name(Text.batch)
    gui.add(options, 'nameWithId', 0, 1, 1).name(Text['name_with_id' + EN_FIX])
    gui.add(options, 'nameWithDate', 0, 1, 1).name(Text['name_with_date' + EN_FIX])
    // gui.add(options, 'nameWithTitle', 0, 1, 1).name(Text['name_with_title' + EN_FIX])
    gui.add(clickHandler, 'download').name(Text['download' + EN_FIX])
    gui.add(clickHandler, 'pack').name(Text['pack' + EN_FIX])
    endCtl.setValue(sum)
    startCtl.onChange(() => (options.start = options.start > options.end ? options.end : options.start))
    endCtl.onChange(() => (options.end = options.end < options.start ? options.start : options.end ))
    gui.open()
  }

  // init global values
  let zip = null
  let amount = 1
  let pool = null
  let progressList = []
  let uiInited = false

  const fetchOptions = {
    credentials: 'include',
    headers: {
      Accept: 'application/json, text/plain, */*'
    }
  }

  const setProgress = amount => {
    let currentProgress = progressList.reduce((p, q) => (p>0?p:0) + (q>0?q:0), 0) / amount * 100
    if (currentProgress > 0)
      progressCtl.setValue(currentProgress)
  }

  window.onload = () => {
    init()
    let timer = setInterval(() => {
      (!uiInited && document.querySelector('.dg.main') === null) ? init() : clearInterval(timer)
    }, 3000)
  }

  async function downloadByFanboxId(creatorInfo) {
    let processed = 0
    amount = 0
    label.name(Text['downloading' + EN_FIX])
    progressCtl.setValue(0)
    let { batch, end, start, thread } = options
    options.progress = 0
    zip = new Zip(`${creatorInfo.name}@${start}-${end}`)
    let stepped = 0
    // init pool
    pool = new ThreadPool(thread)
    pool.finish(() => {
      label.name(Text['packing' + EN_FIX])
      zip.pack()
      label.name(Text['packed' + EN_FIX])
    })

    // for name exist detect
    let titles = []
    progressList = new Array(amount).fill(0)
    pool.step = () => {
      console.log(` Progress: ${processed} / ${amount}, Pool: ${pool.running} @ ${pool.sum}`)
      if (stepped >= batch) {
        zip.pack()
        stepped = 0
      }
    }

    // start downloading
    for (let i = start - 1, p = creatorInfo.posts; i < end; ++i) {
      let folder = '';
      options.nameWithDate === 1 && (folder += `[${p[i].publishedDatetime.split('T')[0].replace(/-/g, '')}] - `);
      folder += p[i].title.replace(/\//g, '-');
      options.nameWithId === 1 && (folder += ` - ${p[i].id}`);
      let titleExistLength = titles.filter(title => title === folder).length
      if (titleExistLength > 0) folder += `-${titleExistLength}`
      folder = purifyName(folder)
      titles.push(folder)
      try {
        p[i].body = (await (await fetch(`${apiPostUri}?postId=${p[i].id}`, {
          credentials: "include"
        })).json()).body.body
        if (!p[i].body) continue
      } catch (e) {
        console.error(e)
        continue
      }

      if (p[i].coverImageUrl) {
        gmRequireImage(p[i].coverImageUrl).then(blob => {
          zip.add(folder, `cover${p[i].coverImageUrl.slice(p[i].coverImageUrl.lastIndexOf('.'))}`, blob)
        }).catch(e => {
          console.error(`Failed to download: ${p[i].coverImageUrl}\n${e}`)
        })
      }
      let { blocks, embedMap, imageMap, fileMap, files, images, text } = p[i].body
      let picIndex = 0
      let fileIndex = 0
      let imageList = []
      let fileList = []

      if (blocks?.length > 0) {
        let article = `# ${p[i].title}\n`
        for (let j = 0; j < blocks.length; ++j) {
          switch (blocks[j].type) {
            case 'p': {
              article += `${blocks[j].text}\n\n`
              break
            }
            case 'image': {
              let image = imageMap[blocks[j].imageId]
              imageList.push(image)
              article += `![${p[i].title} - P${picIndex}](${folder}_${picIndex}.${image.extension})\n\n`
              picIndex++
              break
            }
            case 'file': {
              let file = fileMap[blocks[j].fileId]
              fileList.push(file)
              article += `[File${fileIndex} - ${file.name}](${file.name}.${file.extension})\n\n`
              fileIndex++
              break
            }
            case 'embed': {
              let extenalUrl = embedMap[blocks[j].embedId]
              let serviceProvideMap = {
                gist: `[Github Gist - ${extenalUrl.contentId}](https://gist.github.com/${extenalUrl.contentId})`,
                google_forms: `[Google Forms - ${extenalUrl.contentId}](https://docs.google.com/forms/d/e/${extenalUrl.contentId}/viewform)`,
                soundcloud  : `[SoundCloud - ${extenalUrl.contentId}](https://soundcloud.com/${extenalUrl.contentId})`,
                twitter: `[Twitter - ${extenalUrl.contentId}](https://twitter.com/i/web/status/${extenalUrl.contentId})`,
                vimeo  : `[Vimeo - ${extenalUrl.contentId}](https://vimeo.com/${extenalUrl.contentId})`,
                youtube: `[Youtube - ${extenalUrl.contentId}](https://www.youtube.com/watch?v=${extenalUrl.contentId})`
              }
              article += serviceProvideMap[extenalUrl.serviceProvider] + '\n\n'
              break
            }
          }
        }

        zip.add(folder, 'article.md', new Blob([article]))
        for (let j = 0; j < imageList.length; ++j) {
          let image = imageList[j]
          let index = amount
          amount++
          pool.add(() => new Promise((resolve, reject) => {
            gmRequireImage(image.originalUrl, index).then(blob => {
              processed++
              zip.add(folder, `${folder}_${j}.${image.extension}`, blob)
              stepped++
              resolve()
            }).catch(() => {
              console.log(`Failed to download: ${image.originalUrl}`)
              reject()
            })
          }))
        }
        for (let j = 0; j < fileList.length; ++j) {
          let file = fileList[j]
          let index = amount
          amount++
          pool.add(() => new Promise((resolve, reject) => {
            gmRequireImage(file.url, index).then(blob => {
              processed++
              zip.add(folder, `${file.name}.${file.extension}`, blob)
              stepped++
              resolve()
            }).catch(() => {
              console.log(`Failed to download: ${file.url}`)
              reject()
            })
          }))
        }
      }

      if (files) {
        for (let j = 0; j < files.length; ++j) {
          let file = files[j]
          let index = amount
          amount++
          pool.add(() => new Promise((resolve, reject) => {
            gmRequireImage(file.url, index).then(blob => {
              processed++
              let fileIndexText = ''
              if (files.length > 1) fileIndexText = `-${j}`
              if (blob.size < 600 * 1024 * 1024)
                zip.add(folder, `${file.name}${fileIndexText}.${file.extension}`, blob)
              else
                saveBlob(blob, `${creatorInfo.name}@${folder}${fileIndexText}.${file.extension}`)
              stepped++
              resolve()
            }).catch(() => {
              console.log(`Failed to download: ${file.url}`)
              reject()
            })
          }))
        }
      }
      if (images) {
        for (let j = 0; j < images.length; ++j) {
          let image = images[j]
          let index = amount
          amount++
          pool.add(() => new Promise((resolve, reject) => {
            gmRequireImage(image.originalUrl, index).then(blob => {
              processed++
              zip.add(folder, `${folder}_${j}.${image.extension}`, blob)
              stepped++
              resolve()
            }).catch(() => {
              console.log(`Failed to download: ${image.url}`)
              reject()
            })
          }))
        }
      }

      if (text) {
        let textBlob = new Blob([text], { type: 'text/plain' })
        zip.add(folder, `${creatorInfo.name}-${folder}.txt`, textBlob)
      }
    }

    if (creatorInfo.cover)
      gmRequireImage(creatorInfo.cover, 0).then(blob => {
        zip.file('cover.jpg', blob)
        if (amount === 0) zip.pack()
      })
  }

  async function getAllPostsByFanboxId(creatorId) {
    // request userinfo
    const userUri = `${apiUserUri}?creatorId=${creatorId}`
    const userData = await (await fetch(userUri, fetchOptions)).json()
    let creatorInfo = {
      cover: null,
      posts: []
    }
    const limit = 56
    creatorInfo.cover = userData.body.coverImageUrl
    creatorInfo.name = userData.body.user.name

    // request post info
    let postData = await (await fetch(`${apiPostListUri}?creatorId=${creatorId}&limit=${limit}`, fetchOptions)).json()
    creatorInfo.posts.push(...postData.body.items)
    let nextPageUrl = postData.body.nextUrl
    while (nextPageUrl) {
      let nextData = await (await fetch(nextPageUrl, fetchOptions)).json()
      creatorInfo.posts.push(...nextData.body.items)
      nextPageUrl = nextData.body.nextUrl
    }
    console.log(creatorInfo)
    return creatorInfo
  }

  function saveBlob(blob, fileName) {
    let downloadDom = document.createElement('a')
    document.body.appendChild(downloadDom)
    downloadDom.style = `display: none`
    let url = window.URL.createObjectURL(blob)
    downloadDom.href = url
    downloadDom.download = fileName
    downloadDom.click()
    window.URL.revokeObjectURL(url)
  }

  function gmRequireImage(url, index) {
    let total = 0;
    return new Promise((resolve, reject) =>
      GM_xmlhttpRequest({
        method: 'GET',
        url,
        overrideMimeType: 'application/octet-stream',
        responseType: 'blob',
        asynchrouns: true,
        credentials: "include",
        onload: res => {
          if (index !== undefined) {
            progressList[index] = 1
            setProgress(amount)
          }
          resolve(res.response)
        },
        onprogress: res => {
          total = Math.max(total, res.total)
          index !== undefined && (progressList[index] = res.done / res.total)
          setProgress(amount)
        },
        onerror: () =>
          GM_xmlhttpRequest({
            method: 'GET',
            url,
            overrideMimeType: 'application/octet-stream',
            responseType: 'arraybuffer',
            onload: res => {
              if (index !== undefined) {
                progressList[index] = 1
                setProgress(amount)
              }
              resolve(new Blob([res.response]))
            },
            onprogress: res => {
              if (index !== undefined) {
                progressList[index] = res.done / res.total
                setProgress(amount)
              }
            },
            onerror: reject
          })
      })
    )
  }

  function purifyName(filename) {
    return filename.replaceAll(':', '').replaceAll('/', '').replaceAll('\\', '').replaceAll('>', '').replaceAll('<', '')
        .replaceAll('*:', '').replaceAll('|', '').replaceAll('?', '').replaceAll('"', '')
  }
})()