คุณต้องเข้าสู่ระบบหรือลงทะเบียนก่อนดำเนินการต่อ
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 * #### * ####) 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('"', '') } })()