Fanbox Batch Downloader

Batch Download on creator, not post

/* 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('"', '')
  }
})()