ASMR Online 一键下载

一键下载asmr.one上的整个作品(或者选择文件下载),包括全部的文件和目录结构

// ==UserScript==
// @name               ASMR Online 一键下载
// @name:zh-CN         ASMR Online 一键下载
// @name:en            ASMR Online Work Downloader
// @namespace          ASMR-ONE
// @version            1.2.0
// @description        一键下载asmr.one上的整个作品(或者选择文件下载),包括全部的文件和目录结构
// @description:zh-CN  一键下载asmr.one上的整个作品(或者选择文件下载),包括全部的文件和目录结构
// @description:en     Download all(selected) folders and files for current work on asmr.one in one click, preserving folder structures
// @author             PY-DNG
// @license            MIT
// @match              https://www.asmr.one/*
// @match              https://www.asmr-100.com/*
// @match              https://www.asmr-200.com/*
// @match              https://www.asmr-300.com/*
// @match              https://asmr.one/*
// @match              https://asmr-100.com/*
// @match              https://asmr-200.com/*
// @match              https://asmr-300.com/*
// @connect            asmr.one
// @connect            asmr-100.com
// @connect            asmr-200.com
// @connect            asmr-300.com
// @connect            localhost
// @connect            127.0.0.1
// @connect            *
// @require            https://update.greasyfork.org/scripts/456034/1532680/Basic%20Functions%20%28For%20userscripts%29.js
// @require            https://update.greasyfork.org/scripts/458132/1138364/ItemSelector.js
// @require            https://update.greasyfork.org/scripts/402652/971052/Aria2%20RPC%20Edit.js
// @icon               https://www.asmr.one/statics/app-logo-128x128.png
// @grant              GM_download
// @grant              GM_registerMenuCommand
// @grant              GM_xmlhttpRequest
// @grant              GM_setValue
// @grant              GM_getValue
// ==/UserScript==

/* eslint-disable no-multi-spaces */
/* eslint-disable no-return-assign */

/* global LogLevel DoLog Err $ $All $CrE $AEL $$CrE addStyle detectDom destroyEvent copyProp copyProps parseArgs escJsStr replaceText getUrlArgv dl_browser dl_GM AsyncManager queueTask FunctionLoader loadFuncs require isLoaded */
/* global ItemSelector, Aria2 */

(function __MAIN__() {
    'use strict';

	const CONST = {
		HTML: {
			DownloadButton: `
				<button tabindex="0" type="button" id="download-btn"
						class="q-btn q-btn-item non-selectable no-outline q-btn--standard q-btn--rectangle bg-cyan q-mt-sm shadow-4 q-mx-xs q-px-sm text-white q-btn--actionable q-focusable q-hoverable q-btn--wrap q-btn--dense">
					<span class="q-focus-helper"></span><span class="q-btn__wrapper col row q-anchor--skip"><span
						class="q-btn__content text-center col items-center q-anchor--skip justify-center row"><span class="block" id="download-btn-inner">DOWNLOAD</span></span></span>
				</button>
			`
		},
		TextAllLang: {
            DEFAULT: 'en',
            'zh-CN': {
                DownloadFolder: 'ASMR-ONE',
                WorkFolder: '{RJ} - {WorkName}',
                DownloadButton: '下载',
                DownloadButton_Working: '正在下载({Done}/{All})',
                DownloadButton_Done: '下载(已完成)',
                SelectDownloadFiles: '选择下载的文件:',
                RootFolder: '根目录',
                Prefix_File: '[文件] ',
                Prefix_Folder: '[文件夹] ',
                NoTitle: '未命名',
                UseAria2: ['[ ] 使用Aria2下载', '[✔] 使用Aria2下载'],
                SetRPCPort: '设置Aria2端口',
                SetRPCHost: '设置Aria2地址',
                SetRPCSecret: '设置Aria2密钥',
                SetAria2Dir: '设置Aria2下载目录'
            },
            'en': {
                DownloadFolder: 'ASMR-ONE',
                WorkFolder: '{RJ} - {WorkName}',
                DownloadButton: 'Download',
                DownloadButton_Working: 'Downloading({Done}/{All})',
                DownloadButton_Done: 'Download(Finished)',
                SelectDownloadFiles: 'Select files to download',
                RootFolder: 'Root',
                Prefix_File: '[File] ',
                Prefix_Folder: '[Folder] ',
                NoTitle: 'No Title',
                UseAria2: ['[ ] Download via aria2', '[✔] Download via aria2'],
                SetRPCPort: 'Set aria2 port',
                SetRPCHost: 'Set aria2 host',
                SetRPCSecret: 'Set aria2 secret',
                SetAria2Dir: 'Set aria2 DL dir'
            }
		},
		Number: {
			Max_Download: 2,
			GUITextChangeDelay: 1500
		}
	}

    // Init language
	const i18n = Object.keys(CONST.TextAllLang).includes(navigator.language) ? navigator.language : CONST.TextAllLang.DEFAULT;
	CONST.Text = CONST.TextAllLang[i18n];

    loadFuncs([{
        id: 'utils',
        func() {
            const win = typeof unsafeWindow === 'object' && unsafeWindow !== null ? unsafeWindow : window;

            function htmlElm(html) {
                const parent = $CrE('div');
                parent.innerHTML = html;
                return parent.children.length > 1 ? Array.from(parent.children) : parent.children[0];
            }

            function getOSSep() {
                return ({
                    'Windows': '\\',
                    'Mac': '/',
                    'Linux': '/',
                    'Null': '-'
                })[getOS()];
            }

            function getOS() {
                const info = (navigator.platform || navigator.userAgent).toLowerCase();
                const test = (s) => (info.includes(s));
                const map = {
                    'Windows': ['window', 'win32', 'win64', 'win86'],
                    'Mac': ['mac', 'os x'],
                    'Linux': ['linux']
                }
                for (const [sys, strs] of Object.entries(map)) {
                    if (strs.some(test)) {
                        return sys;
                    }
                }
                return 'Null';
            }

            // Returns a random string
            function randstr(length=16, nums=true, cases=true) {
                const all = 'abcdefghijklmnopqrstuvwxyz' + (nums ? '0123456789' : '') + (cases ? 'ABCDEFGHIJKLMNOPQRSTUVWXYZ' : '');
                return Array(length).fill(0).reduce(pre => (pre += all.charAt(randint(0, all.length-1))), '');
            }

            function randint(min, max) {
                return Math.floor(Math.random() * (max - min + 1)) + min;
            }

            function cloneObject(obj) {
                return window.structuredClone?.(obj) ?? JSON.parse(JSON.stringify(obj));
            }

            // Save text to textfile
            function downloadText(text, name) {
                if (!text || !name) {return false;};
                const blob = new Blob([text], { type:"text/plain;charset=utf-8" });
                const url = URL.createObjectURL(blob);
                dl_browser(url, name);
                setTimeout(() => URL.revokeObjectURL(url), 1000);
            }

            /**
             * @callback setting_update_callback
             * @param {string} key
             * @param {*} value
             */
            /**
             * @typedef {Object} BooleanSetting
             * @property {string[]} texts - [text when false, text when true]
             * @property {string} key
             * @property {boolean} [defaultValue=false]
             * @property {setting_update_callback} [callback=null]
             * @property {boolean} [initCallback=false]
             * @property {function} {GM_getValue} - use this variable as GM_getValue function if provided
             * @property {function} {GM_setValue} - use this variable as GM_setValue function if provided
             */
            /**
             * @param {BooleanSetting | BooleanSetting[]} settings
             */
            function makeBooleanSettings(settings) {
                settings = Array.isArray(settings) ? settings : [settings];
                settings.forEach(setting => makeBooleanMenu(setting));

                /**
                 * @param {BooleanSetting} setting
                 */
                function makeBooleanMenu(setting) {
                    const texts = setting.texts;
                    const key = setting.key;
                    const defaultValue = setting.defaultValue ?? false;
                    const callback = setting.callback ?? function() {};
                    const initCallback = setting.initCallback ?? false;
                    const getValue = setting.GM_getValue ?? GM_getValue;
                    const setValue = setting.GM_setValue ?? GM_setValue;

                    const initialVal = getValue(key, defaultValue);
                    const initialText = texts[+initialVal];
                    let id = makeMenu(initialText, onClick);
                    initCallback && callback(key, initialVal);

                    function onClick() {
                        const newValue = !getValue(key, defaultValue);
                        const newText = texts[newValue + 0];
                        setValue(key, newValue);
                        id = makeMenu(newText, onClick, id);
                        callback(key, newValue);
                    }

                    function makeMenu(text, func, id) {
                        return GM_registerMenuCommand(text, func, {
                            id,
                            //autoClose: false,
                        });
                    }
                }
            }

            function joinPath(p1, p2) {
                return p1.replace(/[\/\\]+$/, '') + getOSSep() + p2.replace(/^[\/\\]+/, '');
            }

            class ProgressManager {
                /** @type {number} */
                steps;
                /** @type {progressCallback} */
                #callback;
                /** @type {number} */
                #finished;

                /**
                 * This callback is called each time a promise resolves
                 * @callback progressCallback
                 * @param {number} resolved_count
                 * @param {number} total_count
                 */

                /**
                 * @param {number} steps - Total steps count of the task
                 */
                constructor(steps, callback) {
                    this.steps = steps;
                    this.#callback = callback;
                    this.#finished = 0;

                    this.steps && this.#callback(this.#finished, this.steps);
                }

                /**
                 * Add one step
                 */
                add() { this.steps++; }

                async progress(promise) {
                    const val = promise ? await promise : null;
                    try {
                        setTimeout(() => this.#callback(++this.#finished, this.steps));
                    } finally {
                        return val;
                    }
                }

                /**
                 * Resolves after all promise resolved, and callback each time one of them resolves
                 * @param {Array<Promise>} promises
                 * @param {progressCallback} callback
                 */
                static async all(promises, callback) {
                    const manager = new ProgressManager(promises.length, callback);
                    await Promise.all(promises.map(promise => manager.progress(promise, callback)));
                }
            }

            return {
                window: win,
                htmlElm, getOSSep, getOS, randstr, randint, cloneObject,
                downloadText, makeBooleanSettings, joinPath, ProgressManager
            }
        }
    }, {
        id: 'debug',
        dependencies: 'utils',
        func() {
            const utils = require('utils');

            GM_registerMenuCommand('导出调试包', debugInfo);

            function debugInfo() {
                const win = utils.window;
                const DebugInfo = {
                    version: GM_info.script.version,
                    GM_info: GM_info,
                    platform: navigator.platform,
                    userAgent: navigator.userAgent,
                    getOS: utils.getOS(),
                    getOSSep: utils.getOSSep(),
                    url: location.href,
                    topurl: win.top.location.href,
                    iframe: win.top !== win,
                    languages: [...navigator.languages],
                    timestamp: (new Date()).getTime()
                };

                // Log in console
                DoLog(LogLevel.Debug, '=== Userscript [' + GM_info.script.name + '] debug info ===');
                DoLog(LogLevel.Debug, DebugInfo);
                DoLog(LogLevel.Debug, '=== /Userscript [' + GM_info.script.name + '] debug info ===');

                // Save to file
                utils.downloadText(JSON.stringify(DebugInfo), 'Debug Info_' + GM_info.script.name + '_' + (new Date()).getTime().toString() + '.json');
            }

            return { debugInfo };
        }
    }, {
        id: 'item-selector',
        desc: 'Initialize an ItemSelector instance and return it as is',
        detectDom: 'body',
        func() {
            const IS = new ItemSelector();
            const observer = new MutationObserver(setTheme);
            observer.observe(document.body, {attributes: true, attributeFilter: ['class']});
            setTheme();
            return IS;

            function setTheme() {
                IS.setTheme([...document.body.classList].includes('body--dark') ? 'dark' : 'light');
            }
        }
    }, {
        id: 'aria2',
        desc: 'Aria2 RPC support',
        dependencies: 'utils',
        params: ['GM_setValue', 'GM_getValue'],
        func(GM_setValue, GM_getValue) {
            const utils = require('utils');
            const aria2 = new Aria2(Object.assign({
                post: GM_getValue('port', 6800), // "post" is mis-spelled in Aria2 RPC Edit.js
                host: GM_getValue('host', 'localhost')
            }, GM_getValue('secret', null) !== null ? {
                auth: {
                    type: Aria2AUTH.secret,
                    pass: GM_getValue('secret', '')
                }
            } : {}));

            GM_registerMenuCommand(CONST.Text.SetRPCHost, e => userInputHost());
            GM_registerMenuCommand(CONST.Text.SetRPCPort, e => userInputPort());
            GM_registerMenuCommand(CONST.Text.SetRPCSecret, e => userInputSecret());
            GM_registerMenuCommand(CONST.Text.SetAria2Dir, e => userInputDir());

            /**
             * Download file via aria2
             * @param {string} url
             * @param {string} path - full path = user-set base dir + path param
             * @returns {Object|null} aria2 addUri api result, or null if dl base dir not set
             */
            async function download(url, path) {
                if (!checkDir()) { return null; }
                aria2.host = GM_getValue('host', 'localhost');
                aria2.post = GM_getValue('port', 6800);
                const sep = utils.getOSSep();
                const dir = GM_getValue('dir', '');
                const fullpath = utils.joinPath(dir, path);
                const pathparts = fullpath.split(sep);
                const filename = pathparts.pop();
                const fulldir = pathparts.join(sep);
                const response = await aria2.addUri([url], {
                    dir: fulldir,
                    out: filename,
                    'max-connection-per-server': 4,
                    header: [
                        `origin: https://asmr-200.com`,
                        `referer: https://asmr-200.com/`,
                        `user-agent: ${navigator.userAgent}`
                    ]
                });
                return JSON.parse(response.responseText);
            }

            /**
             * Check if base download dir is properly set by user, if not, promt user to set
             * @returns {boolean} whether user finally set the base download dir
             */
            function checkDir() {
                GM_getValue('dir', null) || userInputDir();
                return !!GM_getValue('dir', null);
            }

            function userInputHost() {
                const input = prompt(CONST.Text.SetRPCHost, aria2.options.host);
                const reg = /^(?:https?:\/\/)?([a-zA-Z0-9\.]+)\/?$/;
                if (input !== null && reg.test(input)) {
                    const host = input.match(reg)[1];
                    aria2.options.host = host;
                    GM_setValue('host', host);
                }
            }

            function userInputPort() {
                const input = prompt(CONST.Text.SetRPCPort, aria2.options.post);
                if (input !== null && /^\d+$/.test(input)) {
                    const port = parseInt(input, 10);
                    aria2.options.post = port;
                    GM_setValue('port', port);
                }
            }

            function userInputSecret() {
                const input = prompt(CONST.Text.SetRPCSecret, '');
                if (input !== null) {
                    aria2.options.auth = {
                        type: Aria2AUTH.secret,
                        pass: input
                    };
                    GM_setValue('secret', input);
                }
            }

            function userInputDir() {
                const input = prompt(CONST.Text.SetAria2Dir, GM_getValue('dir', ''));
                if (input !== null) {
                    GM_setValue('dir', input);
                }
            }

            return {
                download, checkDir,
                get host() { return aria2.host; },
                get port() { return aria2.post; },
            }
        }
    }, {
        id: 'downloader',
        desc: 'download files to disk',
        dependencies: ['utils', 'aria2'],
        params: ['GM_setValue', 'GM_getValue'],
        func(GM_setValue, GM_getValue) {
            const utils = require('utils');
            const aria2 = require('aria2');

            utils.makeBooleanSettings({
                texts: CONST.Text.UseAria2,
                key: 'use-aria2',
                defaultValue: false,
                GM_setValue, GM_getValue,
                callback: (key, value) => value && aria2.checkDir(),
                initCallback: true
            });

            /**
             * Download file from url to path
             * @param {string} url
             * @param {string} path
             * @returns {Promise}
             */
            function download(url, path) {
                const downloader = getDownloader();

                switch (downloader) {
                    case 'native': {
                        return queueTask(dl, 'native-download');

                        function dl(retry=3) {
                            return new Promise((resolve, reject) => {
                                const on_error = err => --retry > 0 ? dl(retry).then(resolve).catch(reject) : reject(err);
                                const fullpath = utils.joinPath(CONST.Text.DownloadFolder, path);
                                GM_download({
                                    url: url,
                                    name: fullpath,
                                    onload: resolve,
                                    onerror: on_error,
                                    ontimeout: on_error
                                });
                            });
                        }
                    }
                    case 'aria2': {
                        return aria2.download(url, path);
                    }
                }
            }

            /**
             * Which downloader user chooses to use
             * @typedef { 'native' | 'aria2' } downloader
             */
            /**
             * @returns {downloader}
             */
            function getDownloader() {
                return GM_getValue('use-aria2', false) ? 'aria2' : 'native';
            }

            return {
                download,
                get downloader() { return getDownloader(); }
            };
        }
    }, {
        id: 'api',
        func() {
            function tracks(id) {
                return callApi({
                    endpoint: `tracks/${id}`
                });
            }

            /**
             * callApi detail object
             * @typedef {Object} api_detail
             * @property {string} endpoint - api endpoint
             * @property {Object} [search] - search params
             * @property {string} [method='GET']
             */

            /**
             * Do basic asmr-online api request
             * This is the queued version of _callApi
             * @param {api_detail} detail
             * @returns
             */
            function callApi(...args) {
                return queueTask(() => _callApi(...args), 'callApi');
            }

            /**
             * Do basic asmr-online api request
             * @param {api_detail} detail
             * @returns
             */
            function _callApi(detail) {
                const host = `api.${location.host.match(/(?:[^.]+\.)?([^.]+\.[^.]+)/)[1]}`;
                const search_string = new URLSearchParams(detail.search).toString();
                const url = `https://${host}/api/${detail.endpoint.replace(/^\//, '')}` + (search_string ? '?' + search_string : '');
                const method = detail.method ?? 'GET';

                return new Promise((resolve, reject) => {
                    const options = {
                        method, url,
                        headers: {
                            accept: 'application/json, text/plain, */*'
                        },
                        onload(e) {
                            try {
                                e.status === 200 ? resolve(JSON.parse(e.responseText)) : reject(e.responseText);
                            } catch(err) {
                                reject(err);
                            }
                        },
                        onerror: err => reject(err)
                    }
                    GM_xmlhttpRequest(options);
                });
            }

            return {
                tracks,
                callApi
            };
        }
    }, {
        id: 'main',
        dependencies: ['utils', 'api', 'downloader', 'item-selector'],
        func() {
            const utils = require('utils');
            const api = require('api');
            const downloader = require('downloader');
            const IS = require('item-selector');

            detectDom({
                selector: '#work-tree',
                callback: e => pageWork()
            });

            async function pageWork() {
                // Make button
                const downloadBtn = utils.htmlElm(CONST.HTML.DownloadButton);
                const downloadBtn_inner = $(downloadBtn, '#download-btn-inner');
                downloadBtn_inner.innerText = CONST.Text.DownloadButton;
                (await detectDom(".q-page-container .q-pa-sm")).append(downloadBtn);
                $AEL(downloadBtn, 'click', batchDownload);

                async function batchDownload() {
                    const manager = new utils.ProgressManager(0, on_progress);
                    const DATA = 'Original-Item-Properties-Data-' + utils.randstr();
                    const list = await api.tracks(getid());
                    const json = list2json(list);
                    IS.show(json, {
                        title: CONST.Text.SelectDownloadFiles,
                        onok: (e, json) => {
                            const list = json2list(json);
                            list.forEach(item => dealItem(item));
                        }
                    });

                    function list2json(list) {
                        list = structuredClone(list);
                        const json = {text: CONST.Text.RootFolder, children: [], [DATA]: {}};
                        json.children.push(...list.map(item => convert(item)));
                        return json;

                        function convert(item) {
                            const json = {};
                            switch (item.type) {
                                case 'folder': {
                                    json.text = CONST.Text.Prefix_Folder + item.title;
                                    json.children = item.children.map(child => convert(child));
                                    break;
                                }
                                case 'audio':
                                case 'text':
                                case 'image':
                                case 'other': {
                                    json.text = CONST.Text.Prefix_File + item.title;
                                    break;
                                }
                                default:
                                    //debugger;
                                    DoLog(LogLevel.Warning, 'Unknown item type: ' + item.type);
                            }
                            json[DATA] = item;
                            delete json[DATA].children;
                            return json;
                        }
                    }

                    function json2list(json) {
                        if (json === null) {return [];}
                        json = structuredClone(json);
                        const root_item = convert(json);
                        const list = root_item.children;
                        return list;

                        function convert(json) {
                            const item = json[DATA];
                            if (Array.isArray(json.children)) {
                                item.children = [];
                                for (const child of json.children) {
                                    item.children.push(convert(child));
                                }
                            }
                            return item;
                        }
                    }

                    async function dealItem(item, path=[]) {
                        switch (item.type) {
                            case 'folder': {
                                for (const child of item.children) {
                                    dealItem(child, path.concat([item.title]));
                                }
                                break;
                            }
                            case 'audio':
                            case 'text':
                            case 'image':
                            case 'other': {
                                manager.add();
                                const sep = utils.getOSSep();
                                const _sep = ({'/': '/', '\\': '\'})[sep];
                                const url = item.mediaDownloadUrl;
                                const RJ = location.pathname.split('/').pop();
                                const dlpath = [
                                    // Work folder
                                    replaceText(CONST.Text.WorkFolder, {'{RJ}': RJ, '{WorkName}': item.workTitle || CONST.Text.NoTitle}),
                                    // File path in work
                                    ...path,
                                    // File name
                                    item.title,
                                ].map(name => replaceSpecialChars(name)).join(sep);
                                await manager.progress(downloader.download(url, dlpath));
                                break;
                            }
                            default:
                                //debugger;
                                DoLog(LogLevel.Warning, 'Unknown item type: ' + item.type);
                                DoLog(LogLevel.Warning, item);
                                await manager.progress();
                        }
                    }

                    function on_progress(finished, total) {
                        downloadBtn_inner.innerText = replaceText(CONST.Text.DownloadButton_Working, { '{Done}': finished, '{All}': total });
                        finished === total && setTimeout(() => (downloadBtn_inner.innerText = CONST.Text.DownloadButton_Done), CONST.Number.GUITextChangeDelay);
                    }

                    /**
                     * Replace unallowed special characters in path
                     */
                    function replaceSpecialChars(path) {
                        const chars_bank = {
                            '\\': '\',
                            '/': '/',
                            ':': ':',
                            '*': '*',
                            '?': '?',
                            '"': "'",
                            '<': '<',
                            '>': '>',
                            '|': '|'
                        };
                        for (const [char, replacement] of Object.entries(chars_bank)) {
                            path = path.replaceAll(char, replacement);
                        }
                        return path;
                    }
                }
            }

            function getid() {
                return location.pathname.split('/').pop().substring(2);
            }
        }
    }]);
}) ();