Kemono zip download

Download kemono post in a zip file

질문, 리뷰하거나, 이 스크립트를 신고하세요.
// ==UserScript==
// @name               Kemono zip download
// @name:zh-CN         Kemono 下载为ZIP文件
// @namespace          https://greasyfork.org/users/667968-pyudng
// @version            0.8.3
// @description        Download kemono post in a zip file
// @description:zh-CN  下载Kemono的内容为ZIP压缩文档
// @author             PY-DNG
// @license            MIT
// @match              http*://*.kemono.su/*
// @match              http*://*.kemono.party/*
// @require            data:application/javascript,window.setImmediate%20%3D%20window.setImmediate%20%7C%7C%20((f%2C%20...args)%20%3D%3E%20window.setTimeout(()%20%3D%3E%20f(args)%2C%200))%3B
// @require            https://update.greasyfork.org/scripts/456034/1532680/Basic%20Functions%20%28For%20userscripts%29.js
// @require            https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @require            https://update.greasyfork.org/scripts/458132/1138364/ItemSelector.js
// @resource           vue-js      https://unpkg.com/[email protected]/dist/vue.global.prod.js
// @resource           quasar-icon https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons
// @resource           quasar-css  https://cdn.jsdelivr.net/npm/[email protected]/dist/quasar.prod.css
// @resource           quasar-js   https://cdn.jsdelivr.net/npm/[email protected]/dist/quasar.umd.prod.js
// @connect            kemono.su
// @connect            kemono.party
// @icon               https://kemono.su/favicon.ico
// @grant              GM_xmlhttpRequest
// @grant              GM_registerMenuCommand
// @grant              GM_addElement
// @grant              GM_getResourceText
// @grant              GM_setValue
// @grant              GM_getValue
// @grant              GM_addValueChangeListener
// @run-at             document-start
// ==/UserScript==

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

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

(function __MAIN__() {
    'use strict';

	const CONST = {
		TextAllLang: {
			DEFAULT: 'en-US',
			'zh-CN': {
                DownloadZip: '下载为ZIP文件',
                DownloadPost: '下载当前作品为ZIP文件',
                DownloadCreator: '下载当前创作者为ZIP文件',
                Downloading: '正在下载...',
                SelectAll: '全选',
                TotalProgress: '总进度',
                ProgressBoardTitle: '下载进度',
                Settings: '设置',
                SaveContent: '保存文字内容到html文件',
                SaveApijson: '保存api结果到json文件'
            },
            'en-US': {
                DownloadZip: 'Download as ZIP file',
                DownloadPost: 'Download post as ZIP file',
                DownloadCreator: 'Download creator as ZIP file',
                Downloading: 'Downloading...',
                SelectAll: 'Select All',
                TotalProgress: 'Total Progress',
                ProgressBoardTitle: 'Download Progress',
                Settings: 'Settings',
                SaveContent: 'Save text content',
                SaveApijson: 'Save api result'
            }
		}
	};

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

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

            function fillNumber(number, length) {
                const str = number.toString();
                return '0'.repeat(length - str.length) + str;
            }

            /**
             * Async task progress manager \
             * when awaiting async tasks, replace `await task` with `await manager.progress(task)` \
             * suppoerts sub-tasks, just `manager.sub(sub_steps, sub_callback)`
             */
            class ProgressManager {
                /** @type {*} */
                info;
                /** @type {number} */
                steps;
                /** @type {progressCallback} */
                callback;
                /** @type {number} */
                finished;
                /** @type {ProgressManager[]} */
                #children;
                /** @type {ProgressManager} */
                #parent;

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

                /**
                 * @param {number} [steps=0] - total steps count of the task
                 * @param {progressCallback} [callback] - callback each time progress updates
                 * @param {*} [info] - attach any data about this manager if need
                 */
                constructor(steps=0, callback=function() {}, info=undefined) {
                    this.steps = steps;
                    this.callback = callback;
                    this.info = info;
                    this.finished = 0;

                    this.#children = [];
                    this.#callCallback();
                }

                add() { this.steps++; }

                /**
                 * @template {Promise | null} task
                 * @param {task} [promise] - task to await, null is acceptable if no task to await
                 * @param {number} [finished] - set this.finished to this value, defaults to this.finished+1
                 * @returns {Awaited<task>}
                 */
                async progress(promise, finished) {
                    const val = await Promise.resolve(promise);
                    try {
                        this.finished = typeof finished === 'number' ? finished : this.finished + 1;
                        this.#callCallback();
                        //this.finished === this.steps && this.#parent && this.#parent.progress();
                    } finally {
                        return val;
                    }
                }

                /**
                 * Creates a new ProgressManager as a sub-progress of this
                 * @param {number} [steps=0] - total steps count of the task
                 * @param {progressCallback} [callback] - callback each time progress updates, defaulting to parent.callback
                 * @param {*} [info] - attach any data about the sub-manager if need
                 */
                sub(steps, callback, info) {
                    const manager = new ProgressManager(steps ?? 0, callback ?? this.callback, info);
                    manager.#parent = this;
                    this.#children.push(manager);
                    return manager;
                }

                #callCallback() {
                    this.callback(this.finished, this.steps, this);
                }

                get children() {
                    return [...this.#children];
                }

                get parent() {
                    return this.#parent;
                }

                /**
                 * 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)));
                }
            }

            const PerformanceManager = (function() {
                class RunRecord {
                    static #id = 0;

                    /** @typedef {'initialized' | 'running' | 'finished'} run_status */
                    /** @type {number} */
                    id;
                    /** @type {number} */
                    start;
                    /** @type {number} */
                    end;
                    /** @type {number} */
                    duration;
                    /** @type {run_status} */
                    status;
                    /**
                     * Anything for programmers to mark and read, uses as a description for this run
                     * @type {*}
                     */
                    info;

                    /**
                     * @param {*} [info] - Anything for programmers to mark and read, uses as a description for this run
                     */
                    constructor(info) {
                        this.id = RunRecord.#id++;
                        this.status = 'initialized';
                        this.info = info;
                    }

                    run() {
                        const time = performance.now();
                        this.start = time;
                        this.status = 'running';
                        return this;
                    }

                    stop() {
                        const time = performance.now();
                        this.end = time;
                        this.duration = this.end - this.start;
                        this.status = 'finished';
                        return this;
                    }
                }
                class Task {
                    /** @typedef {number | string | symbol} task_id */
                    /** @type {task_id} */
                    id;
                    /** @type {RunRecord[]} */
                    runs;

                    /**
                     * @param {task_id} id
                     */
                    constructor(id) {
                        this.id = id;
                        this.runs = [];
                    }

                    run(info) {
                        const record = new RunRecord(info);
                        record.run();
                        this.runs.push(record);
                        return record;
                    }

                    get time() {
                        return this.runs.reduce((time, record) => {
                            if (record.status === 'finished') {
                                time += record.duration;
                            }
                            return time;
                        }, 0)
                    }
                }
                class PerformanceManager {
                    /** @type {Task[]} */
                    tasks;

                    constructor() {
                        this.tasks = [];
                    }

                    /**
                     * @param {task_id} id
                     * @returns {Task | null}
                     */
                    getTask(id) {
                        return this.tasks.find(task => task.id === id);
                    }

                    /**
                     * Creates a new task
                     * @param {task_id} id
                     * @returns {Task}
                     */
                    newTask(id) {
                        Assert(!this.getTask(id), `given task id ${escJsStr(id)} is already in use`, TypeError);

                        const task = new Task(id);
                        this.tasks.push(task);
                        return task;
                    }

                    /**
                     * Runs a task
                     * @param {task_id} id
                     * @param {*} run_info - Anything for programmers to mark and read, uses as a description for this run
                     * @returns {RunRecord}
                     */
                    run(task_id, run_info) {
                        const task = this.getTask(task_id);
                        Assert(task, `task of id ${escJsStr(task_id)} not found`, TypeError);

                        return task.run(run_info);
                    }

                    totalTime(id) {
                        if (id) {
                            return this.getTask(id).time;
                        } else {
                            return this.tasks.reduce((timetable, task) => {
                                timetable[task.id] = task.time;
                                return timetable;
                            }, {});
                        }
                    }

                    meanTime(id) {
                        if (id) {
                            const task = this.getTask(id);
                            return task.time / task.runs.length;
                        } else {
                            return this.tasks.reduce((timetable, task) => {
                                timetable[task.id] = task.time / task.runs.length;
                                return timetable;
                            }, {});
                        }
                    }
                }

                return PerformanceManager;
            }) ();

            return {
                window: win,
                fillNumber, ProgressManager, PerformanceManager
            }
        }
    }, {
        id: 'dependencies',
        desc: 'load dependencies like vue into the page',
        detectDom: ['head', 'body'],
        async func() {
            const deps = [{
                name: 'vue-js',
                type: 'script',
            }, {
                name: 'quasar-icon',
                type: 'style'
            }, {
                name: 'quasar-css',
                type: 'style'
            }, {
                name: 'quasar-js',
                type: 'script'
            }];

            await Promise.all(deps.map(dep => {
                return new Promise((resolve, reject) => {
                    switch (dep.type) {
                        case 'script': {
                            // Once load, dispatch load event on messager
                            const evt_name = `load:${dep.name};${Date.now()}`;
                            const rand = Math.random().toString();
                            const messager = new EventTarget();
                            const load_code = [
                                '\n;',
                                `window[${escJsStr(rand)}].dispatchEvent(new Event(${escJsStr(evt_name)}));`,
                                `delete window[${escJsStr(rand)}];\n`
                            ].join('\n');
                            unsafeWindow[rand] = messager;
                            $AEL(messager, evt_name, resolve);
                            GM_addElement(document.head, 'script', {
                                textContent: GM_getResourceText(dep.name) + load_code,
                            });
                            break;
                        }
                        case 'style': {
                            GM_addElement(document.head, 'style', {
                                textContent: GM_getResourceText(dep.name),
                            });
                            resolve();
                            break;
                        }
                    }
                });
            }));
        }
    }, {
        id: 'api',
        desc: 'api for kemono',
        async func() {
            let api_key = null;

            const Posts = {
                /**
                 * Get a list of creator posts
                 * @param {string} service - The service where the post is located
                 * @param {number | string} creator_id - The ID of the creator
                 * @param {string} [q] - Search query
                 * @param {number} [o] - Result offset, stepping of 50 is enforced
                 */
                posts(service, creator_id, q, o) {
                    const search = {};
                    q && (search.q = q);
                    o && (search.o = o);
                    return callApi({
                        endpoint: `/${service}/user/${creator_id}`,
                        search
                    });
                },

                /**
                 * Get a specific post
                 * @param {string} service
                 * @param {number | string} creator_id
                 * @param {number | string} post_id
                 * @returns {Promise<Object>}
                 */
                post(service, creator_id, post_id) {
                    return callApi({
                        endpoint: `/${service}/user/${creator_id}/post/${post_id}`
                    });
                }
            };
            const Creators = {
                /**
                 * Get a creator
                 * @param {string} service - The service where the creator is located
                 * @param {number | string} creator_id - The ID of the creator
                 * @returns
                 */
                profile(service, creator_id) {
                    return callApi({
                        endpoint: `/${service}/user/${creator_id}/profile`
                    });
                }
            };
            const Custom = {
                /**
                 * Get a list of creator's ALL posts, calling Post.posts for multiple times and joins the results
                 * @param {string} service - The service where the post is located
                 * @param {number | string} creator_id - The ID of the creator
                 * @param {string} [q] - Search query
                 */
                async all_posts(service, creator_id, q) {
                    const posts = [];
                    let offset = 0;
                    let api_result = null;
                    while (!api_result || api_result.length === 50) {
                        api_result = await Posts.posts(service, creator_id, q, offset);
                        posts.push(...api_result);
                        offset += 50;
                    }
                    return posts;
                }
            };
            const API = {
                get key() { return api_key; },
                set key(val) { api_key = val; },
                Posts, Creators,
                Custom,
                callApi
            };
            return API;

            /**
             * callApi detail object
             * @typedef {Object} api_detail
             * @property {string} endpoint - api endpoint
             * @property {Object} [search] - search params
             * @property {string} [method='GET']
             * @property {boolean | string} [auth=false] - whether to use api-key in this request; true for send, false for not, and string for sending a specific key (instead of pre-configured `api_key`); defaults to false
             */

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

            /**
             * Do basic kemono api request
             * @param {api_detail} detail
             * @returns
             */
            function _callApi(detail) {
                const search_string = new URLSearchParams(detail.search).toString();
                const url = `https://kemono.su/api/v1/${detail.endpoint.replace(/^\//, '')}` + (search_string ? '?' + search_string : '');
                const method = detail.method ?? 'GET';
                const auth = detail.auth ?? false;

                return new Promise((resolve, reject) => {
                    auth && api_key === null && reject('api key not found');

                    const options = {
                        method, url,
                        headers: {
                            accept: 'application/json'
                        },
                        onload(e) {
                            try {
                                e.status === 200 ? resolve(JSON.parse(e.responseText)) : reject(e.responseText);
                            } catch(err) {
                                reject(err);
                            }
                        },
                        onerror: err => reject(err)
                    }
                    if (typeof auth === 'string') {
                        options.headers.Cookie = auth;
                    } else if (auth === true) {
                        options.headers.Cookie = api_key;
                    }
                    GM_xmlhttpRequest(options);
                });
            }
        }
    }, {
        id: 'gui',
        desc: 'reusable GUI components',
        dependencies: 'dependencies',
        async func() {
            class ProgressBoard {
                /** @type {Vue} */
                app;

                /** Vue component instance */
                instance;

                /** @typedef {{ finished: number, total: number }} progress */
                /** @typedef {{ name: string, progress: progress }} item */
                /** @type {item[]} */
                items;

                constructor() {
                    const that = this;
                    this.items = [];
                    
                    // GUI
                    const container = $$CrE({ tagName: 'div', styles: { position: 'fixed', zIndex: '-1' } });
                    const board = $$CrE({
                        tagName: 'div',
                        classes: 'board'
                    });
                    board.innerHTML = `
                        <q-layout view="hhh lpr fff">
                            <q-dialog v-model="show" :class="{ mobile: $q.platform.is.mobile }" :position="$q.platform.is.mobile ? 'standard' : 'bottom'" seamless :full-width="$q.platform.is.mobile" :full-height="$q.platform.is.mobile">
                                <q-card class="container-card">
                                    <q-card-section class="header row">
                                        <div class="text-h6">${ CONST.Text.ProgressBoardTitle }</div>
                                        <q-space></q-space>
                                        <q-btn :icon="minimized ? 'expand_less' : 'expand_more'" flat round dense @click="minimized = !minimized"></q-btn>
                                        <q-btn icon="close" flat round dense v-close-popup></q-btn>
                                    </q-card-section>
                                    <q-slide-transition>
                                        <q-card-section class="body" v-show="!minimized">
                                            <q-list class="list" :class="{ minimized: minimized }">
                                                <q-item tag="div" v-for="item of items">
                                                    <q-item-section>
                                                        <q-item-label>{{ item.name }}</q-item-label>
                                                    </q-item-section>
                                                    <q-item-section>
                                                        <q-linear-progress animation-speed="500" :indeterminate="item.progress.total === 0" :value="item.progress.total > 0 ? item.progress.finished / item.progress.total : 0" :color="item.progress.total > 0 && item.progress.total === item.progress.finished ? 'green' : 'blue'"></q-linear-progress>
                                                    </q-item-section>
                                                </q-item>
                                            </q-list>
                                        </q-card-section>
                                    </q-slide-transition>
                                </q-card>
                            </q-dialog>
                        </q-layout>
                    `;
                    container.append(board);
                    detectDom('html').then(html => html.append(container));

                    detectDom('head').then(head => addStyle(`
                        :is(.container-card):not(.mobile *) {
                            max-width: 45vw;
                        }
                        :is(.header, .body):not(.mobile *) {
                            width: 40vw;
                        }
                        :is(.body .list):not(.mobile *) {
                            height: 40vh;
                            overflow-y: auto;
                        }
                        :is(.body .list.minimized):not(.mobile *) {
                            overflow-y: hidden;
                        }
                    `, 'progress-board-style'));

                    this.app = Vue.createApp({
                        data() {
                            return {
                                items: that.items,
                                minimized: false,
                                show: false
                            }
                        },
                        methods: {
                            debug() {
                                debugger;
                            }
                        },
                        mounted() {
                            that.instance = this;
                        }
                    });
                    this.app.use(Quasar);
                    this.app.mount(board);
                }

                /**
                 * update item's progress display on progress_board
                 * @param {string} name - must be unique among all items
                 * @param {progress} progress
                 */
                update(name, progress) {
                    let item = this.instance.items.find(item => item.name === name);
                    if (!item) {
                        item = { name, progress };
                        this.instance.items.push(item);
                    }
                    item.progress = progress;
                }

                /**
                 * remove an item
                 * @param {string} name
                 */
                remove(name) {
                    let item_index = this.instance.items.findIndex(item => item.name === name);
                    if (item_index === -1) { return null; }
                    return this.instance.items.splice(item_index, 1)[0];
                }

                /**
                 * remove all existing items
                 */
                clear() {
                   this.instance.items.splice(0, this.instance.items.length);
                }

                get minimized() {
                    return this.instance.minimized;
                }

                set minimized(val) {
                    this.instance.minimized = !!val;
                }

                get closed() {
                    return !this.instance.show;
                }

                set closed(val) {
                    this.instance.show = !val;
                }
            }

            const board = new ProgressBoard();

            return { ProgressBoard, board };
        }
    }, {
        id: 'downloader',
        desc: 'core zip download utils',
        dependencies: ['utils', 'api', 'settings'],
        async func() {
            const utils = require('utils');
            const API = require('api');
            const settings = require('settings');

            // Performance record
            const perfmon = new utils.PerformanceManager();
            perfmon.newTask('fetchPost');
            perfmon.newTask('saveAs');
            perfmon.newTask('_fetchBlob');


            class DownloaderItem {
                /** @typedef {'file' | 'folder'} downloader_item_type */
                /**
                 * Name of the item, CANNOT BE PATH
                 * @type {string}
                 */
                name;
                /** @type {downloader_item_type} */
                type;

                /**
                 * @param {string} name
                 * @param {downloader_item_type} type
                 */
                constructor(name, type) {
                    this.name = name;
                    this.type = type;
                }
            }

            class DownloaderFile extends DownloaderItem{
                /** @type {Blob} */
                data;
                /** @type {Date} */
                date;
                /** @type {string} */
                comment;

                /**
                 * @param {string} name - name only, CANNOT BE PATH
                 * @param {Blob} data
                 * @param {Object} detail
                 * @property {Date} [date]
                 * @property {string} [comment]
                 */
                constructor(name, data, detail) {
                    super(name, 'file');
                    this.data = data;
                    Object.assign(this, detail);
                }

                zip(jszip_instance) {
                    const z = jszip_instance ?? new JSZip();
                    const options = {};
                    this.date && (options.date = this.date);
                    this.comment && (options.comment = this.comment);
                    z.file(this.name, this.data, options);
                    return z;
                }
            }

            class DownloaderFolder extends DownloaderItem {
                /** @type {Array<DownloaderFile | DownloaderFolder>} */
                children;

                /**
                 * @param {string} name - name only, CANNOT BE PATH
                 * @param {Array<DownloaderFile | DownloaderFolder>} [children]
                 */
                constructor(name, children) {
                    super(name, 'folder');
                    this.children = children && children.length ? children : [];
                }

                zip(jszip_instance) {
                    const z = jszip_instance ?? new JSZip();
                    for (const child of this.children) {
                        switch (child.type) {
                            case 'file': {
                                child.zip(z);
                                break;
                            }
                            case 'folder': {
                                const sub_z = z.folder(child.name);
                                child.zip(sub_z);
                            }
                        }
                    }
                    return z;
                }
            }

            /**
             * @typedef {Object} JSZip
             */
            /**
             * @typedef {Object} zipObject
             */

            /**
             * Download one post in zip file
             * @param {string} service
             * @param {number | string} creator_id
             * @param {number | string} post_id
             * @param {function} [callback] - called each time made some progress, with two numeric arguments: finished_steps and total_steps
             */
            async function downloadPost(service, creator_id, post_id, callback = function() {}) {
                const manager = new utils.ProgressManager(3, callback, {
                    layer: 'root', service, creator_id, post_id
                });
                const data = await manager.progress(API.Posts.post(service, creator_id, post_id));
                const folder = await manager.progress(fetchPost(data, manager));
                const zip = folder.zip();
                const filename = `${data.post.title}.zip`;
                manager.progress(await saveAs(filename, zip, manager));
            }

            /**
             * @typedef {Object} PostInfo
             * @property {string} service,
             * @property {number} creator_id
             * @property {number} post_id
             */

            /**
             * Download one or multiple posts in zip file, one folder for each post
             * @param {PostInfo | PostInfo[]} posts
             * @param {string} filename - file name of final zip file to be delivered to the user
             * @param {*} callback - Progress callback, like downloadPost's callback param
             */
            async function downloadPosts(posts, filename, callback = function() {}) {
                Array.isArray(posts) || (posts = [posts]);
                const manager = new utils.ProgressManager(2, callback, {
                    layer: 'root', posts, filename
                });

                // Fetch posts
                const post_folders = await manager.progress(Promise.all(
                    posts.map(async post => {
                        const data = await API.Posts.post(post.service, post.creator_id, post.post_id);
                        const folder = await fetchPost(data, manager);
                        return folder;
                    })
                ));

                // Merge all post's folders into one
                const folder = new DownloaderFolder(filename);
                post_folders.map(async post_folder => {
                    folder.children.push(post_folder);
                })

                // Convert folder to zip
                const zip = folder.zip();

                // Deliver to user
                await manager.progress(saveAs(filename, zip, manager));
            }

            /**
             * Fetch one post
             * @param {Object} api_data - kemono post api returned data object
             * @param {utils.ProgressManager} [manager]
             * @returns {Promise<DownloaderFolder>}
             */
            async function fetchPost(api_data, manager) {
                const perfmon_run = perfmon.run('fetchPost', api_data);
                const sub_manager = manager.sub(0, manager.callback, {
                    layer: 'post', data: api_data
                });

                const date = new Date(api_data.post.edited);
                const folder = new DownloaderFolder(escapePath(api_data.post.title), [
                    ...settings.save_apijson ? [new DownloaderFile('data.json', JSON.stringify(api_data))] : [],
                    ...settings.save_content ? [new DownloaderFile('content.html', api_data.post.content, { date })] : []
                ]);

                const tasks = [
                    // api_data.post.file
                    (async function(file) {
                        if (!file.path) { return; }
                        sub_manager.add();
                        const name = escapePath(`file-${getFileName(file)}`);
                        const url = getFileUrl(file);
                        const blob = await sub_manager.progress(fetchBlob(url, sub_manager));
                        folder.children.push(new DownloaderFile(
                            name, blob, {
                                date,
                                comment: JSON.stringify(file)
                            }
                        ));
                    }) (api_data.post.file),

                    // api_data.post.attachments
                    ...api_data.post.attachments.map(async (attachment, i) => {
                        if (!attachment.path) { return; }
                        sub_manager.add();
                        const prefix = utils.fillNumber(i+1, api_data.post.attachments.length.toString().length);
                        const name = escapePath(`${prefix}-${getFileName(attachment)}`);
                        const url = getFileUrl(attachment);
                        const blob = await sub_manager.progress(fetchBlob(url, sub_manager));
                        const attachments_folder = new DownloaderFolder('attachments');
                        attachments_folder.children.push(new DownloaderFile(
                            name, blob, {
                                date,
                                comment: JSON.stringify(attachment)
                            }
                        ));
                        folder.children.push(attachments_folder);
                    }),
                ];
                await Promise.all(tasks);

                // Make sure sub_manager finishes even when no async tasks
                if (sub_manager.steps === 0) {
                    sub_manager.steps = 1;
                    await sub_manager.progress();
                }

                perfmon_run.stop();

                return folder;

                function getFileName(file) {
                    return file.name || file.path.slice(file.path.lastIndexOf('/')+1);
                }

                function getFileUrl(file) {
                    return (file.server ?? 'https://n1.kemono.su') + '/data' + file.path;
                }
            }

            /**
             * Deliver zip file to user
             * @param {string} filename - filename (with extension, e.g. "file.zip")
             * @param {JSZip} zip,
             * @param {string | null} comment - zip file comment
             */
            async function saveAs(filename, zip, manager, comment=null) {
                const perfmon_run = perfmon.run('saveAs', filename);
                const sub_manager = manager.sub(100, manager.callback, {
                    layer: 'zip', filename, zip
                });

                const options = { type: 'blob' };
                comment !== null && (options.comment = comment);
                const blob = await zip.generateAsync(options, metadata => sub_manager.progress(null, metadata.percent));
                const url = URL.createObjectURL(blob);
                const a = $$CrE({
                    tagName: 'a',
                    attrs: {
                        download: filename,
                        href: url
                    }
                });
                a.click();

                perfmon_run.stop();
            }

            /**
             * Fetch blob data from given url \
             * Queued function of _fetchBlob
             * @param {string} url
             * @param {utils.ProgressManager} [manager]
             * @returns {Promise<Blob>}
             */
            function fetchBlob(...args) {
                if (!fetchBlob.initialized) {
                    queueTask.fetchBlob = {
                        max: 3,
                        sleep: 0
                    };
                    fetchBlob.initialized = true;
                }

                return queueTask(() => _fetchBlob(...args), 'fetchBlob');
            }

            /**
             * Fetch blob data from given url
             * @param {string} url
             * @param {utils.ProgressManager} [manager]
             * @param {number} [retry=3] - times to retry before throwing an error
             * @returns {Promise<Blob>}
             */
            async function _fetchBlob(url, manager, retry = 3) {
                const perfmon_run = perfmon.run('_fetchBlob', url);
                const sub_manager = manager.sub(0, manager.callback, {
                    layer: 'file', url
                });

                const blob = await new Promise((resolve, reject) => {
                    GM_xmlhttpRequest({
                        method: 'GET', url,
                        responseType: 'blob',
                        async onprogress(e) {
                            sub_manager.steps = e.total;
                            await sub_manager.progress(null, e.loaded);
                        },
                        onload(e) {
                            e.status === 200 ? resolve(e.response) : onerror(e)
                        },
                        onerror
                    });

                    async function onerror(err) {
                        if (retry) {
                            await sub_manager.progress(null, -1);
                            const result = await _fetchBlob(url, manager, retry - 1);
                            resolve(result);
                        } else {
                            reject(err)
                        }
                    }
                });

                perfmon_run.stop();

                return blob;
            }

            /**
             * Replace unallowed special characters in a path part
             * @param {string} path - a part of path, such as a folder name / file name
             */
            function escapePath(path) {
                // Replace special characters
                const chars_bank = {
                    '\\': '\',
                    '/': '/',
                    ':': ':',
                    '*': '*',
                    '?': '?',
                    '"': "'",
                    '<': '<',
                    '>': '>',
                    '|': '|'
                };
                for (const [char, replacement] of Object.entries(chars_bank)) {
                    path = path.replaceAll(char, replacement);
                }

                // Disallow ending with dots
                path.endsWith('.') && (path += '_');
                return path;
            }

            return {
                downloadPost, downloadPosts,
                fetchPost, saveAs,
                perfmon
            };
        }
    }, {
        id: 'settings',
        detectDom: 'html',
        dependencies: 'dependencies',
        params: ['GM_setValue', 'GM_getValue'],
        async func(GM_setValue, GM_getValue) {
            // settings 读/写对象,既用于oFunc内读写,也直接作为oFunc返回值提供给其他功能函数
            const settings = {
                get save_apijson() {
                    return GM_getValue('save_apijson', true);
                },
                set save_apijson(val) {
                    GM_setValue('save_apijson', !!val);
                },
                get save_content() {
                    return GM_getValue('save_content', true);
                },
                set save_content(val) {
                    GM_setValue('save_content', !!val);
                }
            }

            // 创建设置界面
            const container = $$CrE({
                tagName: 'div',
                styles: { all: 'initial', position: 'fixed' }
            })
            const app_elm = $CrE('div');
            app_elm.innerHTML = `
                <q-layout view="hhh lpr fff">
                    <q-dialog v-model="show">
                        <q-card>
                            <q-card-section>
                                <div class="text-h6">${ CONST.Text.Settings }</div>
                            </q-card-section>
                            <q-card-section>
                                <q-list>
                                    <q-item tag="label" v-ripple>
                                        <q-item-section>
                                            <q-item-label>${ CONST.Text.SaveContent }</q-item-label>
                                            <q-item-label caption>content.html</q-item-label>
                                        </q-item-section>
                                        <q-item-section avatar>
                                            <q-toggle color="orange" v-model="save_content" @update:model-value="val => update('save_content', val)"></q-toggle>
                                        </q-item-section>
                                    </q-item>

                                    <q-item tag="label" v-ripple>
                                        <q-item-section>
                                            <q-item-label>${ CONST.Text.SaveApijson}</q-item-label>
                                            <q-item-label caption>data.json</q-item-label>
                                        </q-item-section>
                                        <q-item-section avatar>
                                            <q-toggle color="orange" v-model="save_apijson" @update:model-value="val => update('save_apijson', val)"></q-toggle>
                                        </q-item-section>
                                    </q-item>
                                </q-list>
                            </q-card-section>
                        </q-card>
                    </q-dialog>
                </q-layout>
            `;
            container.append(app_elm);
            $('html').append(container);
            const app = Vue.createApp({
                data() {
                    return {
                        show: false,
                        save_content: settings.save_content,
                        save_apijson: settings.save_apijson,
                    };
                },
                methods: {
                    update(name, val) {
                        GM_setValue(name, val);
                    }
                },
                mounted() {
                    GM_registerMenuCommand(CONST.Text.Settings, e => this.show = true);
                    GM_addValueChangeListener('settings', () => {
                        this.save_content = settings.save_content;
                        this.save_apijson = settings.save_apijson;
                    });
                }
            });
            app.use(Quasar);
            app.mount(app_elm);
            Quasar.Dark.set('auto');

            return settings;
        }
    }, {
        id: 'user-interface',
        dependencies: ['utils', 'api', 'downloader', 'gui'],
        async func() {
            const utils = require('utils');
            const api = require('api');
            const downloader = require('downloader');
            const gui = require('gui');

            let downloading = false;
            let selector = null;

            // Console User Interface
            const ConsoleUI = utils.window.ZIP = {
                version: GM_info.script.version,
                require,

                api, downloader,
                get ui() { return require('user-interface') },

                downloadCurrentPost, downloadCurrentCreator, userDownload, getPageType
            };

            // Menu User Interface
            GM_registerMenuCommand(CONST.Text.DownloadPost, downloadCurrentPost);
            GM_registerMenuCommand(CONST.Text.DownloadCreator, downloadCurrentCreator);

            // Graphical User Interface
            // Make button
            const dlbtn = $$CrE({
                tagName: 'button',
                styles: {
                    'background-color': 'transparent',
                    'color': 'white',
                    'border': 'transparent'
                },
                listeners: [['click', userDownload]]
            });
            const dltext = $$CrE({
                tagName: 'span',
                props: {
                    innerText: CONST.Text.DownloadZip
                }
            });
            const dlprogress = $$CrE({
                tagName: 'span',
                styles: {
                    display: 'none',
                    'margin-left': '10px'
                }
            });
            dlbtn.append(dltext);
            dlbtn.append(dlprogress);

            const board = gui.board;

            // Place button each time a new action panel appears (meaning navigating into a post page)
            let observer;
            detectDom({
                selector: '.post__actions, .user-header__actions',
                callback: action_panel => {
                    // Hide dlprogress, its content is still for previous page
                    dlprogress.style.display = 'none';

                    // Append to action panel
                    action_panel.append(dlbtn);

                    // Disconnect old observer
                    observer?.disconnect();

                    // Observe action panel content change, always put download button in last place
                    observer = detectDom({
                        root: action_panel,
                        selector: 'button',
                        callback: btn => btn !== dlbtn && action_panel.append(dlbtn)
                    });
                }
            });

            return {
                ConsoleUI,
                dlbtn, dltext,
                get ['selector']() { return selector; },
                downloadCurrentPost, downloadCurrentCreator,
                getCurrentPost, getCurrentCreator, getPageType
            }

            function userDownload() {
                const page_type = getPageType();
                const func = ({
                    post: downloadCurrentPost,
                    creator: downloadCurrentCreator
                })[page_type] ?? function() {};
                return func();
            }

            async function downloadCurrentPost() {
                const post_info = getCurrentPost();
                if (downloading) { return; }
                if (!post_info) { return; }

                try {
                    downloading = true;
                    dlprogress.style.display = 'inline';
                    dltext.innerText = CONST.Text.Downloading;
                    board.minimized = false;
                    board.closed = false;
                    board.clear();
                    await downloader.downloadPost(
                        post_info.service,
                        post_info.creator_id,
                        post_info.post_id,
                        on_progress
                    );
                } finally {
                    downloading = false;
                    dltext.innerText = CONST.Text.DownloadZip;
                }

                function on_progress(finished_steps, total_steps, manager) {
                    const info = manager.info;
                    info.layer === 'root' && (dlprogress.innerText = `(${finished_steps}/${total_steps})`);

                    const progress = { finished: finished_steps, total: total_steps };
                    info.layer === 'root' && board.update(CONST.Text.TotalProgress, progress);
                    info.layer === 'post' && board.update(info.data.post.title, progress);
                    info.layer === 'file' && board.update(info.url, progress);
                    info.layer === 'zip' && board.update(info.filename, progress);
                    board.closed = false;
                }
            }

            async function downloadCurrentCreator() {
                const creator_info = getCurrentCreator();
                if (downloading) { return; }
                if (!creator_info) { return; }

                try {
                    downloading = true;
                    const profile = await api.Creators.profile(creator_info.service, creator_info.creator_id);
                    const posts = await api.Custom.all_posts(creator_info.service, creator_info.creator_id);
                    const selected_posts = await new Promise((resolve, reject) => {
                        if (!selector) {
                            selector = new ItemSelector();
                            selector.setTheme('dark');
                            selector.elements.container.style.setProperty('z-index', '1');
                        }
                        selector.show({
                            text: CONST.Text.SelectAll,
                            children: posts.map(post => ({
                                post,
                                text: post.title
                            }))
                        }, {
                            title: CONST.Text.DownloadCreator,
                            onok(e, json) {
                                const posts = json.children.map(obj => obj.post);
                                resolve(posts);
                            },
                            oncancel: e => resolve(null),
                            onclose: e => resolve(null)
                        });
                    });
                    if (selected_posts) {
                        dlprogress.style.display = 'inline';
                        dltext.innerText = CONST.Text.Downloading;
                        board.minimized = false;
                        board.closed = false;
                        board.clear();
                        await downloader.downloadPosts(selected_posts.map(post => ({
                            service: creator_info.service,
                            creator_id: creator_info.creator_id,
                            post_id: post.id
                        })), `${profile.name}.zip`, on_progress);
                    }
                } finally {
                    downloading = false;
                    dltext.innerText = CONST.Text.DownloadZip;
                }

                function on_progress(finished_steps, total_steps, manager) {
                    const info = manager.info;
                    info.layer === 'root' && (dlprogress.innerText = `(${finished_steps}/${total_steps})`);

                    const progress = { finished: finished_steps, total: total_steps };
                    info.layer === 'root' && board.update(CONST.Text.TotalProgress, progress);
                    info.layer === 'post' && board.update(info.data.post.title, progress);
                    info.layer === 'file' && board.update(info.url, progress);
                    info.layer === 'zip' && board.update(info.filename, progress);
                    board.closed = false;
                }
            }

            /**
             * @typedef {Object} PostInfo
             * @property {string} service,
             * @property {number} creator_id
             * @property {number} post_id
             */
            /**
             * Get post info in current page
             * @returns {PostInfo | null}
             */
            function getCurrentPost() {
                const regpath = /^\/([a-zA-Z]+)\/user\/(\d+)\/post\/(\d+)/;
                const match = location.pathname.match(regpath);
                if (!match) {
                    return null;
                } else {
                    return {
                        service: match[1],
                        creator_id: parseInt(match[2], 10),
                        post_id: parseInt(match[3], 10)
                    }
                }
            }

            /**
             * @typedef {Object} CreatorInfo
             * @property {string} service
             * @property {number} creator_id
             */
            /**
             * Get creator info in current page
             * @returns {CreatorInfo | null}
             */
            function getCurrentCreator() {
                const regpath = /^\/([a-zA-Z]+)\/user\/(\d+)/;
                const match = location.pathname.match(regpath);
                if (!match) {
                    return null;
                } else {
                    return {
                        service: match[1],
                        creator_id: parseInt(match[2], 10)
                    }
                }
            }

            /** @typedef { 'post' | 'creator' } page_type */
            /**
             * @returns {page_type}
             */
            function getPageType() {
                const matchers = {
                    post: {
                        type: 'regpath',
                        value: /^\/([a-zA-Z]+)\/user\/(\d+)\/post\/(\d+)/
                    },
                    creator: {
                        type: 'func',
                        value: () => /^\/([a-zA-Z]+)\/user\/(\d+)/.test(location.pathname) && !location.pathname.includes('/post/')
                    },
                }
                for (const [type, matcher] of Object.entries(matchers)) {
                    if (FunctionLoader.testCheckers(matcher)) {
                        return type;
                    }
                }
            }
        },
    }]);
}) ();