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