Kemono zip download

Download kemono post in a zip file

נכון ליום 11-03-2025. ראה הגרסה האחרונה.

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name               Kemono zip download
// @name:zh-CN         Kemono 下载为ZIP文件
// @namespace          https://greasyfork.org/users/667968-pyudng
// @version            0.6
// @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: '设置'
            },
            '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'
            }
		}
	};

	// 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',
        async func() {
            class ProgressBoard {
                /** @type {Object<string, HTMLElement>} */
                elements;
                items;

                constructor() {
                    const container = $CrE('div');
                    const shadowroot = container.attachShadow({ mode: 'open' });
                    const board = $$CrE({
                        tagName: 'div',
                        classes: 'board'
                    });
                    const header = $$CrE({
                        tagName: 'div',
                        classes: 'header'
                    });
                    const body = $$CrE({
                        tagName: 'div',
                        classes: 'body'
                    });
                    const title = $$CrE({
                        tagName: 'span',
                        classes: 'title',
                        props: { innerText: CONST.Text.ProgressBoardTitle }
                    });
                    const btn_group = $$CrE({
                        tagName: 'span',
                        classes: 'header-buttons'
                    });
                    const close = $$CrE({
                        tagName: 'span',
                        classes: 'close',
                        props: { innerText: 'x' },
                        listeners: [['click', e => board.classList.toggle('show')]]
                    });
                    const minimize = $$CrE({
                        tagName: 'span',
                        classes: 'minimize',
                        props: { innerText: '-' },
                        listeners: [['click', e => body.classList.toggle('show')]]
                    });
                    const style = $$CrE({
                        tagName: 'style',
                        props: {
                            innerText: `
                                .board {
                                    position: fixed;
                                    right: 0;
                                    bottom: 0;
                                    z-index: 1;
                                    background-color: rgb(40, 42, 46);
                                    color: var(--colour0-primary);
                                    width: 40vw;
                                    display: none;
                                }

                                .show:not(#important) {
                                    display: block;
                                }

                                .header {
                                    height: 1.5em;
                                }

                                .header-buttons {
                                    float: right;
                                    direction: rtl;
                                }

                                .header-buttons>* {
                                    display: inline-block;
                                    text-align: center;
                                    width: 1.5em;
                                    font-size: 1.2em;
                                    font-weight: bolder;
                                    cursor: pointer;
                                }

                                .body {
                                    overflow: auto;
                                    display: none;
                                    height: calc(40vh - 1.5em);
                                }

                                .header, .body {
                                    padding: 10px;
                                }

                                .line {
                                    display: flex;
                                    flex-direction: row;
                                    align-items: center;
                                }

                                .text {
                                    width: calc(60% - 10px);
                                    display: inline-block;
                                    overflow: hidden;
                                    padding-right: 10px;
                                }

                                .progress {
                                    width: 40%;
                                }
                            `
                        }
                    });
                    btn_group.append(close, minimize);
                    header.append(title, btn_group);
                    board.append(header, body);
                    shadowroot.append(board, style);
                    detectDom('body').then(body => body.append(container));

                    this.elements = {
                        container, shadowroot, board, header,
                        body, close, minimize, style
                    };
                    this.items = {};
                }

                /**
                 * update item's progress display on progress_board
                 * @param {string} name - must be unique among all items
                 * @param {Object} progress
                 * @param {number} progress.finished
                 * @param {number} progress.total
                 */
                update(name, progress) {
                    const elements = this.elements;
                    if (!this.items[name]) {
                        const line = $$CrE({ tagName: 'p', classes: 'line' });
                        const text = $$CrE({ tagName: 'span', classes: 'text', props: { innerText: name } });
                        const progbar = $$CrE({tagName: 'progress', classes: 'progress' });
                        line.append(text, progbar);
                        elements.body.append(line);
                        this.items[name] = { line, text, progbar };
                    }
                    elements.board.classList.add('show');
                    this.items[name].progbar.max = progress.total;
                    this.items[name].progbar.value = progress.finished;
                    this.items[name].progbar.title = `${progress.finished / progress.total * 100}%`;
                }

                /**
                 * remove an item
                 * @param {string} name
                 */
                remove(name) {
                    const item = this.items[name];
                    if (!item) { return null; }
                    item.line.remove();
                    delete this.items[name];
                    return item;
                }

                /**
                 * remove all existing items
                 */
                clear() {
                    for (const name of Object.keys(this.items)) {
                        this.remove(name);
                    }
                }

                get minimized() {
                    return !this.elements.body.classList.contains('show');
                }

                set minimized(val) {
                    this.elements.body.classList[val ? 'remove' : 'add']('show');
                }

                get closed() {
                    return !this.elements.container.classList.contains('show');
                }

                set closed(val) {
                    this.elements.container.classList[val ? 'remove' : 'add']('show');
                }
            }

            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 {*} 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(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 = `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 = `${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;
            }

            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>保存文字内容到html文件</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>保存api结果到json文件</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.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);
                }
            }

            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.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);
                }
            }

            /**
             * @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;
                    }
                }
            }
        },
    }]);
}) ();