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.20
// @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/1558509/Basic%20Functions%20%28For%20userscripts%29.js
// @require            https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
// @resource           vue-js      https://unpkg.com/[email protected]/dist/vue.global.prod.js
// @resource           vue-js      https://cdn.jsdelivr.net/npm/[email protected]/dist/vue.global.min.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
// @resource           quasar-icon-bak https://google-fonts.mirrors.sjtug.sjtu.edu.cn/css?family=Roboto:100,300,400,500,700,900|Material+Icons
// @resource           quasar-css-bak  https://unpkg.com/[email protected]/dist/quasar.prod.css
// @resource           quasar-js-bak   https://unpkg.com/[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
// @grant              GM_setClipboard
// @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 Vue Quasar */

(function __MAIN__() {
    'use strict';

	const CONST = {
		TextAllLang: {
			DEFAULT: 'en-US',
			'zh-CN': {
                DownloadZip: '下载为ZIP文件',
                DownloadPost: '下载当前作品为ZIP文件',
                DownloadCreator: '下载当前创作者为ZIP文件',
                DownloadCustom: '自定义下载',
                Downloading: '正在下载...',
                FetchingCreatorPosts: '正在获取创作者作品列表...',
                SaveDirMissingTitle: '保存位置未设置',
                SaveDirMissing: '您目前开启了FileSystemAPI保存文件,请先在设置中选择文件保存位置',
                Settings: {
                    Title: '设置',
                    SaveContent: '保存文字内容到html文件',
                    SaveApijson: '保存api结果到json文件',
                    NoPrefix: '保存文件时不添加文件名前缀',
                    FlattenFiles: '保存主文件和附件到同一级文件夹',
                    FlattenFilesCaption: '主文件一般是封面图',
                    NumberPrefix: '使用数字作为主文件的前缀',
                    NumberPrefixCaption: '一般主文件使用"file-"作为前缀,开启此选项后将使用和附件一样的数字作为前缀,以便排序',
                    UseFileSystemAPI: '使用FileSystemAPI保存文件',
                    UseFileSystemAPICaption: '使用此API理论上可以保存更大的文件,但可能每次页面刷新后都需要用户重新手动授权',
                    FileSystemAPINotSupported: '您的浏览器不支持此功能;最新版的Chrome或Edge浏览器支持此功能',
                    FileSystemAPINotSupportedTitle: '啊哦Σ(っ °Д °;)っ',
                    NoFolderSelected: '未选择',
                    SaveDir: 'FileSystemAPI下载位置',
                    SaveDirCaption: '使用FileSystemAPI时保存到此位置,需要用户主动选择授权',
                },
                ProgressBoard: {
                    TotalProgress: '总进度',
                    TotalProgDesc: '共 {PostsCount} 篇内容需要下载',
                    PostProgDesc: '内有 {FilesCount} 个文件需要下载',
                    ZipProgDesc: '创建ZIP压缩包',
                    ProgressBoardTitle: '下载进度',
                },
                TaskDetail: {
                    Title: '任务详情',
                    TaskName: '任务名称:',
                    TaskDesc: '任务描述:',
                    TaskProgress: '任务进度:',
                    TaskProgDetail: '任务具体进度:',
                    CopyButton: '复制任务信息',
                    OkayButton: '朕知道了,下去吧',
                },
                CustomDownload: {
                    PopupTitle: '自定义下载',
                    TypeLabel: '下载...',
                    ServiceLabel: '发布平台',
                    CreatorLabel: '创作者ID',
                    PostLabel: '内容ID',
                    ExtFilterLabel: '只下载以下扩展名的文件',
                    ExtFilterTooltip: '留空以下载所有文件',
                    MoreOptions: '更多选项',
                    Type: {
                        Post: '单篇内容',
                        Creator: '创作者的多篇内容'
                    },
                    Cancel: '取消',
                    Download: '开始下载',
                },
                PostsSelector: {
                    Title: '选择内容',
                    OK: '确定',
                    Cancel: '取消',
                    SelectAll: '全选',
                },
            },
            'en-US': {
                DownloadZip: 'Download as ZIP file',
                DownloadPost: 'Download post as ZIP file',
                DownloadCreator: 'Download creator as ZIP file',
                DownloadCustom: 'Custom download',
                Downloading: 'Downloading...',
                FetchingCreatorPosts: 'Fetching creator posts list...',
                SelectAll: 'Select All',
                SaveDirMissingTitle: 'Download location missing',
                SaveDirMissing: 'You have enabled FileSystemAPI, please set FileSystemAPI download location before downloading',
                Settings: {
                    Title: 'Settings',
                    SaveContent: 'Save text content',
                    SaveApijson: 'Save api result',
                    NoPrefix: 'Do not add filename prefix',
                    FlattenFiles: 'Save main file and attachments to same folder',
                    FlattenFilesCaption: '"Main file" is usually the cover image',
                    NumberPrefix: 'Use number as filename prefix for the main file',
                    NumberPrefixCaption: 'Replace "file-" prefix with number to sort it in order with other files',
                    UseFileSystemAPI: 'Use File API for saving ZIP files',
                    UseFileSystemAPICaption: 'File API supports saving larger file than usual, but requires user granting permission each time',
                    FileSystemAPINotSupported: 'This feature is not supported in your browser, please use latest chrome or edge to use it',
                    FileSystemAPINotSupportedTitle: 'Ah-oh Σ(っ °Д °;)っ',
                    NoFolderSelected: 'Not provided',
                    SaveDir: 'FileSystemAPI download location',
                    SaveDirCaption: 'Used when FileSystemAPI is enabled',
                },
                ProgressBoard: {
                    TotalProgress: 'Total Progress',
                    TotalProgDesc: '{PostsCount} posts to download in total',
                    PostProgDesc: '{FilesCount} files to download inside',
                    ZipProgDesc: 'Compressing all files into a zip file',
                    ProgressBoardTitle: 'Download Progress',
                },
                TaskDetail: {
                    Title: 'Task Detail',
                    TaskName: 'Name: ',
                    TaskDesc: 'Description: ',
                    TaskProgress: 'Progress: ',
                    TaskProgDetail: 'Progress detail: ',
                    CopyButton: 'Copy task info',
                    OkayButton: 'Okay',
                },
                CustomDownload: {
                    PopupTitle: 'Custom download',
                    TypeLabel: 'Download ...',
                    ServiceLabel: 'Service Name',
                    CreatorLabel: 'Creator ID',
                    PostLabel: '内容',
                    ExtFilterLabel: 'Download files with these extensions only',
                    ExtFilterTooltip: 'Leave blank to download all files',
                    MoreOptions: 'More options',
                    Type: {
                        Post: 'single post',
                        Creator: 'creator posts'
                    },
                    Cancel: 'Cancel',
                    Download: 'Download',
                },
                PostsSelector: {
                    Title: 'Select posts',
                    OK: 'OK',
                    Cancel: 'Cancel',
                    SelectAll: 'Select All',
                },
            }
		},
        get Text() {
            const i18n = Object.keys(CONST.TextAllLang).includes(navigator.language) ? navigator.language : CONST.TextAllLang.DEFAULT;
            return CONST.TextAllLang[i18n];
        }
	};

    /**
     * @typedef {Object} setting
     * @property {string} title - 设置项名称
     * @property {string | null} [caption] - 设置项副标题,可以省略;为假值时不渲染副标题元素
     * @property {string} key - 用作settings 读/写对象的prop名称,也用作v-model值
     * @property {string} [storage] - GM存储key;提供时直接在存储空间读写,不提供时,仅作内存读写(实例间不共享,页面刷新重置)
     * @property {boolean} [temp=false] - 是否在自定义下载中展示,以允许临时覆盖;不提供时默认为false
     * @property {*} default - 用户未设置过时的默认值(初始值)
     */
    /** @type {setting[]} */
    CONST.Settings = [{
        type: 'boolean',
        title: CONST.Text.Settings.SaveContent,
        caption: 'content.html',
        key: 'save_content',
        storage: 'save_content',
        default: true,
        temp: true,
    }, {
        type: 'boolean',
        title: CONST.Text.Settings.SaveApijson,
        caption: 'data.json',
        key: 'save_apijson',
        storage: 'save_apijson',
        default: true,
        temp: true,
    }, {
        type: 'boolean',
        title: CONST.Text.Settings.FlattenFiles,
        caption: CONST.Text.Settings.FlattenFilesCaption,
        key: 'flatten_files',
        storage: 'flatten_files',
        default: false,
        temp: true,
    }, {
        type: 'boolean',
        title: CONST.Text.Settings.NoPrefix,
        key: 'no_prefix',
        storage: 'no_prefix',
        default: false,
        temp: true,
    }, {
        type: 'boolean',
        title: CONST.Text.Settings.NumberPrefix,
        caption: CONST.Text.Settings.NumberPrefixCaption,
        key: 'number_prefix',
        storage: 'number_prefix',
        default: false,
        temp: true,
    }, {
        type: 'boolean',
        title: CONST.Text.Settings.UseFileSystemAPI,
        caption: CONST.Text.Settings.UseFileSystemAPICaption,
        key: 'fileapi',
        storage: 'fileapi',
        default: false,
        temp: true,
    }, {
        type: 'folder',
        title: CONST.Text.Settings.SaveDir,
        caption: CONST.Text.Settings.SaveDirCaption,
        key: 'savedir',
        storage: false,
        default: null,
        temp: true,
    }];

    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 extends EventTarget {
                /** @type {*} */
                info;
                /** @type {number} */
                steps;
                /** @type {number} */
                finished;
                /** @type {'none' | 'sub' | 'self'} */
                error;
                /** @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, info=undefined) {
                    super();

                    this.steps = steps;
                    this.info = info;
                    this.finished = 0;
                    this.error = 'none';

                    this.#children = [];
                    this.#broadcast('progress');
                }

                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, adds 1 to this.finished if omitted
                 * @param {boolean} [accept_reject=true] - whether to treat rejected promise as resolved; if true, callback will get error object in arguments; if not, progress function itself rejects
                 * @returns {Awaited<task>}
                 */
                async progress(promise, finished, accept_reject = true) {
                    let val;
                    try {
                        val = await Promise.resolve(promise);
                    } catch(err) {
                        this.newError('self', false);
                        if (!accept_reject) {
                            throw err;
                        }
                    }
                    try {
                        this.finished = (typeof finished === 'number' && finished >= 0) ? finished : this.finished + 1;
                        this.#broadcast('progress');
                        //this.finished === this.steps && this.#parent && this.#parent.progress();
                    } finally {
                        return val;
                    }
                }

                /**
                 * New error occured in manager's scope, update error status
                 * @param {'none' | 'sub' | 'self'} [error='self']
                 * @param {boolean} [callCallback=true]
                 */
                newError(error = 'self', callCallback = true) {
                    const error_level = ['none', 'sub', 'self'];
                    if (error_level.indexOf(error) <= error_level.indexOf(this.error)) { return; }

                    this.error = error;
                    this.#parent && this.#parent.newError('sub');
                    callCallback && this.#broadcast('error');
                }

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

                /**
                 * reset this to an empty manager
                 */
                reset() {
                    this.steps = 0;
                    this.finished = 0;
                    this.#parent = null;
                    this.#children = [];
                    this.#broadcast('reset');
                }

                #broadcast(evt_name) {
                    //this.callback(this.finished, this.steps, this);
                    this.dispatchEvent(new CustomEvent(evt_name, {
                        detail: {
                            type: evt_name,
                            manager: this
                        }
                    }));
                }

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

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

            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 StandbySuffix = '-bak';
            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) => {
                    const resource_text = GM_getResourceText(dep.name) || GM_getResourceText(dep.name + StandbySuffix);
                    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: resource_text + load_code,
                            });
                            break;
                        }
                        case 'style': {
                            GM_addElement(document.head, 'style', {
                                textContent: resource_text,
                            });
                            resolve();
                            break;
                        }
                    }
                });
            }));

            Quasar.setCssVar('primary', 'orange');
            setTimeout(() => Quasar.Dark.set(true));

            // some fixes
            addStyle(`
                #main h1 {
                    font-size: 2em;
                    line-height: 1.5;
                    letter-spacing: unset;
                }
                #main h2 {
                    font-size: 1.6rem;
                    line-height: 1.35;
                    letter-spacing: unset;
                }
                #main h3 {
                    font-weight: 400;
                    font-size: 1.5rem;
                    line-height: 1.5;
                    letter-spacing: inherit;
                }
                body.q-body--force-scrollbar-y {
                   overflow-y: unset !important;
                }
            `)
        }
    }, {
        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', 'settings', 'utils'],
        async func() {
            const settings = require('settings');
            const utils = require('utils');

            class ProgressBoard {
                app;
                instance;

                constructor() {
                    const that = this;

                    // 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="showing" :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.ProgressBoard.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-progress-item :manager="manager" v-if="manager"></q-progress-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;
                        }
                        .container-card :is(.header, .body):not(.mobile *) {
                            width: 40vw;
                        }
                        .container-card :is(.body .list):not(.mobile *) {
                            height: 40vh;
                            overflow-y: auto;
                        }
                        .container-card :is(.body .list.minimized):not(.mobile *) {
                            overflow-y: hidden;
                        }
                    `, 'progress-board-style'));

                    this.app = Vue.createApp({
                        data() {
                            return {
                                board: that,
                                showing: false,
                                minimized: false,
                                manager: null
                            }
                        },
                        methods: {
                            show(manager) {
                                this.manager = manager;
                                this.showing = true;
                                this.minimized = false;
                            }
                        },
                        mounted() {
                            that.instance = this;
                        }
                    });
                    this.app.use(Quasar);

                    // 嵌套进度组件
                    /**
                     * 嵌套进度组件设计:
                     * - 该组件和ProgressManager紧密耦合,所有参数信息均包含在ProgressManager实例中
                     * - 该组件仅需传入一个根ProgressManager实例,所有嵌套子组件均根据根实例分支而来
                     * - 传入的ProgressManager必须有着以下标准的info属性:
                     *   - @property {'root' | 'blob' | 'api' | 'zip' | 'folder'} manager.info.type 用于展示对应图标
                     *   - @property {string} manager.info.name 显示的主要文字,一个短语描述其任务
                     *   - @property {string} [manager.info.desc] 显示的次要文字,一句话描述任务信息
                     */
                    this.app.component('q-progress-item', {
                        name: 'QProgressItem',
                        props: [ 'manager' ],
                        template: `
                            <q-item class="column">
                                <q-linear-progress
                                    :value="progress"
                                    :color="color"
                                    :indeterminate="indeterminate"
                                ></q-linear-progress>

                                <q-expansion-item
                                    expand-separator
                                    v-if="sub_managers.length"
                                    :icon="icon"
                                    :label="manager.info.name"
                                    :caption="manager.info.desc"
                                >
                                    <q-progress-item
                                        v-for="sub_manager of sub_managers"
                                        :manager="sub_manager"
                                    ></q-progress-item>
                                </q-expansion-item>

                                <q-item v-else clickable @dblclick="showDetail">
                                    <q-item-section avatar>
                                        <q-icon :name="icon"></q-icon>
                                    </q-item-section>

                                    <q-item-section>
                                        <q-item-label>{{ manager.info.name }}</q-item-label>
                                        <q-item-label caption>{{ manager.info.desc }}</q-item-label>
                                    </q-item-section>
                                </q-item>
                            </q-item>
                        `,
                        data() {
                            return {
                                finished: this.manager.finished,
                                total: this.manager.steps,
                                error: this.manager.error,
                                sub_managers: this.manager.children
                            }
                        },
                        computed: {
                            progress() {
                                return this.total !== 0 ? this.finished / this.total : 1;
                            },
                            indeterminate() {
                                return this.total === 0 && this.error !== 'self';
                            },
                            icon() {
                                return ({
                                    root: 'folder_zip',
                                    blob: 'description',
                                    api: 'api',
                                    zip: 'compress',
                                    folder: 'folder'
                                })[this.manager.info.type] ?? 'draft'; /** @TODO 需要由调用方将标准化的参数填入ProgressManager的info字段 */
                            },
                            color() {
                                return ({
                                    none: this.finished === this.total && this.total > 0 ? 'green' : 'blue',
                                    sub: this.finished === this.total ? 'orange' : 'blue',
                                    self: 'red'
                                })[this.error];
                            },
                            TaskTextInfo() {
                                const TaskDetail = CONST.Text.TaskDetail;
                                const info = this.manager.info;
                                return [
                                    TaskDetail.TaskName + info.name,
                                    TaskDetail.TaskDesc + info.desc,
                                    TaskDetail.TaskProgress + `${ this.progress * 100 }%`,
                                    TaskDetail.TaskProgDetail + `${ this.finished } / ${ this.total }`,
                                ].join('\n');
                            },
                        },
                        methods: {
                            showDetail(e) {
                                const dialog = Quasar.Dialog.create({
                                    title: CONST.Text.TaskDetail.Title,
                                    message: '',
                                    html: true,
                                    color: 'primary',
                                    ok: {
                                        label: CONST.Text.TaskDetail.OkayButton
                                    },
                                    cancel: {
                                        label: CONST.Text.TaskDetail.CopyButton
                                    },
                                }).onOk(() => {
                                    // Close popup
                                    // Nothing to do yet
                                }).onCancel(() => {
                                    // Copy task info
                                    this.copyInfo();
                                }).onDismiss(() => {
                                    // When dialog isdismissed, no matter how
                                    unwatch();
                                });
                                const unwatch = this.$watch('TaskTextInfo', (newInfo, oldInfo) => {
                                    dialog.update({
                                        message: newInfo.replaceAll('\n', '<br>')
                                    });
                                }, {
                                    immediate: true
                                });
                            },
                            copyInfo() {
                                GM_setClipboard(this.TaskTextInfo, 'text');
                            }
                        },
                        watch: {
                            manager: {
                                handler(new_manager, old_manager) {
                                    const that = this;
                                    $AEL(new_manager, 'sub', e => {
                                        that.sub_managers = new_manager.children;
                                    });
                                    $AEL(new_manager, 'progress', e => {
                                        that.finished = new_manager.finished;
                                        that.total = new_manager.steps;
                                    });
                                    $AEL(new_manager, 'error', e => {
                                        that.error = new_manager.error;
                                    });
                                    $AEL(new_manager, 'reset', e => fullRefresh());
                                    fullRefresh();

                                    function fullRefresh() {
                                        that.sub_managers = new_manager.children;
                                        that.finished = new_manager.finished;
                                        that.total = new_manager.steps;
                                        that.error = new_manager.error;
                                    }
                                },
                                immediate: true
                            }
                        }
                    });
                    this.app.mount(board);
                }

                /**
                 * @param {ProgressManager} manager 
                 */
                show(manager) {
                    this.instance.show(manager);
                }
            }

            const board = new ProgressBoard();

            class CustomDownloadPanel {
                app;
                instance;
                #promise; // Promise to be created on show() call, and resolved on user submit
                #resolve; // resolve function for the promise above

                constructor() {
                    const that = this;
                    
                    // GUI
                    const container = $$CrE({ tagName: 'div', styles: { position: 'fixed', zIndex: '-1' } });
                    const panel = $$CrE({
                        tagName: 'div',
                        classes: 'panel'
                    });
                    panel.innerHTML = `
                        <q-layout view="hhh lpr fff">
                            <q-dialog v-model="show" :class="{ mobile: $q.platform.is.mobile }" :full-width="$q.platform.is.mobile" :full-height="$q.platform.is.mobile" @hide="cancel">
                                <q-card class="container-card-custom-dl">
                                    <q-card-section class="header row">
                                        <div class="text-h6">${ CONST.Text.CustomDownload.PopupTitle }</div>
                                        <q-space></q-space>
                                        <q-btn icon="close" flat round dense v-close-popup></q-btn>
                                    </q-card-section>
                                    <q-card-section class="body q-pa-md scroll">
                                        <q-list>
                                            <q-item>
                                                <q-item-section>
                                                    <q-select hide-bottom-space filled v-model="download_type" :options="avail_dl_types" label="${ CONST.Text.CustomDownload.TypeLabel }" ref="dltype_ref" :rules="[ val => !!val ]" lazy-rules></q-select>
                                                </q-item-section>
                                            </q-item>
                                            <q-item>
                                                <q-item-section>
                                                    <q-select hide-bottom-space filled v-model="service" :options="avail_services" label="${ CONST.Text.CustomDownload.ServiceLabel }" ref="service_ref" :rules="[ val => !!val ]" lazy-rules></q-select>
                                                </q-item-section>
                                            </q-item>
                                            <q-item>
                                                <q-item-section>
                                                   <q-input hide-bottom-space filled type="number" v-model.number="creator_id" label="${ CONST.Text.CustomDownload.CreatorLabel }" ref="creator_ref" :rules="[ val => !!val ]" lazy-rules></q-input>
                                                </q-item-section>
                                            </q-item>
                                            <q-item v-if="download_type.value === 'post'">
                                                <q-item-section>
                                                    <q-input hide-bottom-space filled type="number" v-model.number="post_id" label="${ CONST.Text.CustomDownload.PostLabel }" ref="post_ref" :rules="[ val => download_type.value !== 'post' || !!val ]" lazy-rules></q-input>
                                                </q-item-section>
                                            </q-item>
                                            <q-item>
                                                <q-item-section>
                                                    <q-input hide-bottom-space filled v-model="ext_filter" label-slot>
                                                        <template v-slot:label>
                                                            <div class="row items-center all-pointer-events">
                                                                ${ CONST.Text.CustomDownload.ExtFilterLabel }
                                                                <q-tooltip class="bg-grey-8" anchor="top left" self="bottom left" :offset="[0, 8]">
                                                                    ${ CONST.Text.CustomDownload.ExtFilterTooltip }
                                                                </q-tooltip>
                                                            </div>
                                                        </template>
                                                    </q-input>
                                                </q-item-section>
                                            </q-item>
                                            
                                            <q-expansion-item clickable expand-separator label="${ CONST.Text.CustomDownload.MoreOptions }">
                                                <q-list>
                                                    <q-item tag="label" clickable v-for="setting of settings_data">
                                                        <q-item-section>
                                                            <q-item-label>{{ setting.title }}</q-item-label>
                                                            <q-item-label caption v-if="!!setting.caption">{{ setting.caption }}</q-item-label>
                                                        </q-item-section>
                                                        <q-item-section side top>
                                                            <q-checkbox v-if="setting.type === 'boolean'" left-label v-model="temp_settings[setting.key]"></q-checkbox>
                                                            <div v-else-if="setting.type === 'folder'">
                                                                <span style="margin-right: 0.5em;">{{ temp_settings[setting.key]?.name ?? ${ escJsStr(CONST.Text.Settings.NoFolderSelected) } }}</span>
                                                                <q-btn icon="folder_open" color="primary" @click="requestFolder(setting.key)"></q-button>
                                                            </div>
                                                        </q-item-section>
                                                    </q-item>
                                                </q-list>
                                            </q-expansion-item>
                                        </q-list>
                                    </q-card-section>
                                    <q-card-actions :align="$q.platform.is.mobile ? 'center' : 'right'">
                                        <q-btn flat v-close-popup>${ CONST.Text.CustomDownload.Cancel }</q-btn>
                                        <q-btn flat @click="submit" :loading="loading" color="primary">${ CONST.Text.CustomDownload.Download }</q-btn>
                                    </q-card-action>
                                </q-card>
                            </q-dialog>
                        </q-layout>
                    `;
                    container.append(panel);
                    detectDom('html').then(html => html.append(container));
                    detectDom('head').then(head => addStyle(`
                        .container-card-custom-dl :is([type=text], input[type=password], input[type=number]) {
                            box-shadow: unset;
                            background: unset;
                        }
                        .container-card-custom-dl :is(.header, .body):not(.mobile *) {
                            width: 30vw;
                            min-width: 175px;
                        }
                        .container-card-custom-dl .body {
                            max-height: 75vh;
                        }
                    `));

                    this.app = Vue.createApp({
                        data() {
                            return {
                                panel: that,
                                temp_settings: {},
                                show: false,
                                autoclose: true,
                                download_type: null,
                                service: '',
                                creator_id: 0,
                                post_id: 0,
                                ext_filter: '',
                                loading: false,
                                settings_data: CONST.Settings.filter(setting => setting.temp),
                                avail_dl_types: [{
                                    label: CONST.Text.CustomDownload.Type.Post,
                                    value: 'post'
                                }, {
                                    label: CONST.Text.CustomDownload.Type.Creator,
                                    value: 'creator'
                                }],
                                avail_services: [{
                                    "label": "Patreon",
                                    "value": "patreon"
                                }, {
                                    "label": "Pixiv Fanbox",
                                    "value": "fanbox"
                                }, {
                                    "label": "Discord",
                                    "value": "discord"
                                }, {
                                    "label": "Fantia",
                                    "value": "fantia"
                                }, {
                                    "label": "Afdian",
                                    "value": "afdian"
                                }, {
                                    "label": "Boosty",
                                    "value": "boosty"
                                }, {
                                    "label": "Gumroad",
                                    "value": "gumroad"
                                }, {
                                    "label": "SubscribeStar",
                                    "value": "subscribestar"
                                }, {
                                    "label": "DLsite",
                                    "value": "dlsite"
                                }]
                            }
                        },
                        methods: {
                            /**
                             * @param {string} key
                             */
                            async requestFolder(key) {
                                /** @type {Window} */
                                const win = utils.window;
                                if (!win.showDirectoryPicker) {
                                    Quasar.Dialog.create({
                                        title: CONST.Text.Settings.FileSystemAPINotSupportedTitle,
                                        message: CONST.Text.Settings.FileSystemAPINotSupported,
                                    });
                                    return;
                                }
                                const dir_handle = await win.showDirectoryPicker({
                                    id: key,
                                    mode: 'readwrite',
                                    startIn: 'downloads'
                                });
                                this.temp_settings[key] = dir_handle;
                            },
                            submit() {
                                if (!this.validate()) { return; }
                                this.show = !this.autoclose;
                                this.resolve({
                                    download_type: this.download_type.value,
                                    service: this.service.value,
                                    creator_id: this.creator_id,
                                    post_id: this.post_id,
                                    ext_filter: this.ext_filter.split(/[, ]+/).map(ext => ext.replace(/^\.+/, '')).filter(ext => ext.length),
                                    settings: this.temp_settings
                                });
                            },
                            cancel() {
                                // This will also be invoked after download button clicked
                                // Because the dialog popup will be closed after download button clicked
                                // Since only the first call to the resolve function takes effect to the promise
                                // This does not make anything wrong
                                this.resolve(null);
                            },
                            validate() {
                                const refs = ['dltype_ref', 'service_ref', 'creator_ref', 'post_ref'].map(ref_name => this.$refs[ref_name]).filter(ref => !!ref);
                                refs.forEach(ref => ref.validate());
                                return !refs.some(ref => ref.hasError);
                            }
                        },
                        mounted() {
                            that.instance = this;
                        }
                    });
                    this.app.use(Quasar);
                    this.app.mount(panel);
                }

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

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

                get showing() {
                    return this.instance.show;
                }
                
                /** @typedef {'post' | 'creator'} download_type */
                /** @typedef {'patreon' | 'fanbox' | 'discord' | 'fantia' | 'afdian' | 'boosty' | 'gumroad' | 'subscribestar' | 'dlsite'} kemono_service */
                /**
                 * Display and wait for user submit
                 * @param {Object} defaultValue
                 * @param {download_type} defaultValue.download_type
                 * @param {kemono_service} defaultValue.service
                 * @param {number | string} defaultValue.creator_id
                 * @param {number | string} [defaultValue.post_id]
                 * @returns {Promise<null | { download_type: download_type, service: kemono_service, creator_id: number, post_id?: number, autoclose: boolean }>}
                 */
                show({
                    download_type = 'post', service = 'patreon', creator_id = null, post_id = null,
                    autoclose = true
                } = {}) {
                    if (this.showing) { return this.#promise; }
                    ({ promise: this.#promise, resolve: this.#resolve } = Promise.withResolvers());

                    // 传入的属性
                    this.instance.resolve = this.#resolve;
                    this.instance.download_type = this.instance.avail_dl_types.find(obj => obj.value === download_type);
                    this.instance.service = this.instance.avail_services.find(obj => obj.value === service);
                    this.instance.creator_id = parseInt(creator_id, 10);
                    this.instance.post_id = parseInt(post_id, 10);
                    this.instance.autoclose = autoclose;

                    // 一次性的下载覆写设置
                    CONST.Settings.filter(s => s.temp).forEach(setting => {
                        this.instance.temp_settings[setting.key] = settings[setting.key];
                    });

                    // 展示下载框
                    this.instance.show = true;

                    return this.#promise;
                }

                close() {
                    this.instance.show = false;
                }
            }

            const panel = new CustomDownloadPanel();

            class PostsSelector {
                app;
                instance;
                posts;
                #promise; // Promise to be created on show() call, and resolved on user submit
                #resolve; // resolve function for the promise above

                /**
                 * @typedef {Object} post
                 * @property {number} id
                 * @property {number} user
                 * @property {string} service
                 * @property {string} title
                 * @property {string} content
                 * @property {Object} embed
                 * @property {boolean} shared_file
                 * @property {string} added
                 * @property {string} published
                 * @property {string} edited
                 * @property {Object} file
                 * @property {Object} attachments
                 * @property {*} poll
                 * @property {*} captions
                 * @property {*} tags
                 */

                constructor() {
                    const that = this;
                    
                    // GUI
                    const container = $$CrE({ tagName: 'div', styles: { position: 'fixed', zIndex: '-1' } });
                    const panel = $$CrE({
                        tagName: 'div',
                        classes: 'panel'
                    });

                    panel.innerHTML = `
                        <q-layout view="hhh lpr fff">
                            <q-dialog v-model="show" :class="{ mobile: $q.platform.is.mobile }" :full-width="$q.platform.is.mobile" :full-height="$q.platform.is.mobile" @hide="cancel">
                                <q-card class="container-card-posts-selector">
                                    <q-card-section class="header row">
                                        <div class="text-h6">${ CONST.Text.PostsSelector.Title }</div>
                                        <q-space></q-space>
                                        <q-btn icon="close" flat round dense v-close-popup></q-btn>
                                    </q-card-section>
                                    <q-card-section class="body q-pa-md scroll">
                                        <q-list separator>
                                            <q-item tag="label" clickable v-ripple>
                                                <q-item-section>
                                                    <q-item-label lines="1">${ CONST.Text.PostsSelector.SelectAll }</q-item-label>
                                                </q-item-section>
                                                <q-item-section side>
                                                    <q-checkbox v-model="select_all" @update:model-value="val => selectAll(val)"></q-checkbox>
                                                </q-item-section>
                                            </q-item>
                                            <q-item v-for="post of posts" tag="label" clickable v-ripple>
                                                <q-item-section avatar>
                                                    <q-avatar square>
                                                        <q-img v-if="!!post.file?.path" fit="cover" ratio="1" loading="lazy" :src="getThumbnail(post.file)" :id="\`thumbnail-\${post.id}\`">
                                                            <q-tooltip>
                                                                <img :src="getThumbnail(post.file)" loading="lazy" class="image-preview">
                                                            </q-tooltip>
                                                        </q-img>
                                                        <q-icon v-else name="hide_image"></q-icon>
                                                    </q-avatar>
                                                </q-item-section>
                                                <q-item-section>
                                                    <q-item-label lines="1">{{ post.title }}</q-item-label>
                                                </q-item-section>
                                                <q-item-section side>
                                                    <q-btn icon="open_in_new" flat target="_blank" :href="\`/\${ post.service }/user/\${ post.user }/post/\${ post.id }\`"></q-btn>
                                                </q-item-section>
                                                <q-item-section side>
                                                    <q-checkbox v-model="selected_posts" :val="post" @update:model-value="onSelect"></q-checkbox>
                                                </q-item-section>
                                            </q-item>
                                        </q-list>
                                    </q-card-section>
                                    <q-card-actions :align="$q.platform.is.mobile ? 'center' : 'right'">
                                        <q-btn flat v-close-popup>${ CONST.Text.PostsSelector.Cancel }</q-btn>
                                        <q-btn flat @click="submit" color="primary">${ CONST.Text.PostsSelector.OK }</q-btn>
                                    </q-card-action>
                                </q-card>
                            </q-dialog>
                        </q-layout>
                    `;

                    container.append(panel);
                    detectDom('html').then(html => html.append(container));
                    detectDom('head').then(head => addStyle(`
                        .container-card-posts-selector :is(.header, .body):not(.mobile *) {
                            min-width: 175px;
                        }
                        .container-card-posts-selector .body {
                            max-height: 75vh;
                        }
                        
                        /* 根据屏幕宽高确定图像大小 */
                        /* 当屏幕宽度大于高度时(横屏/宽屏) */
                        @media (min-aspect-ratio: 1/1) {
                            .image-preview {
                                height: 40vh;
                                width: auto;
                            }
                        }
                        /* 当屏幕高度大于等于宽度时(竖屏/方屏) */
                        @media (max-aspect-ratio: 1/1) {
                            .image-preview {
                                width: 40vw;
                                height: auto;
                            }
                        }
                    `));

                    this.app = Vue.createApp({
                        data() {
                            return {
                                selector: that,
                                show: false,
                                select_all: false,
                                /** @type {post[]} */
                                posts: [],
                                /** @type {post[]} */
                                selected_posts: [],
                            }
                        },
                        methods: {
                            getThumbnail(file) {
                                return 'https://img.kemono.su/thumbnail/data' + file.path;
                            },
                            submit() {
                                if (!this.validate()) { return; }
                                this.show = false;
                                this.resolve(this.selected_posts.map(
                                    post => this.selector.posts.find(p => p.id === post.id)
                                ));
                            },
                            cancel() {
                                // This will also be invoked after submit button clicked
                                // Because the dialog popup will be closed after download button clicked
                                // Since only the first call to the resolve function takes effect to the promise
                                // This does not make anything wrong
                                this.selected_posts.splice(0, this.selected_posts.length);
                                this.resolve(null);
                            },
                            validate() {
                                const refs = [].map(ref_name => this.$refs[ref_name]).filter(ref => !!ref);
                                refs.forEach(ref => ref.validate());
                                return !refs.some(ref => ref.hasError);
                            },
                            selectAll(selected) {
                                if (selected ? this.selected_posts.length === this.posts.length : !this.selected_posts.length) { return; }
                                this.selected_posts.splice(0, this.selected_posts.length);
                                selected && this.selected_posts.push(...this.posts);
                                selected && !this.select_all && (this.select_all = true);
                            },
                            onSelect() {
                                // Sort selected_posts by posts
                                this.selected_posts.sort((p1, p2) => this.posts.indexOf(p2) - this.posts.indexOf(p1));

                                // Update select_all status
                                const select_all = this.selected_posts.length === this.posts.length ? true : (this.selected_posts.length ? null : false);
                                select_all !== this.select_all && (this.select_all = select_all);
                            },
                        },
                        mounted() {
                            that.instance = this;
                        }
                    });
                    this.app.use(Quasar);
                    this.app.mount(panel);

                }

                /**
                 * 
                 * @param {Object} detail
                 * @param {post[]} detail.posts
                 * @param {post[]} [detail.select_all] - whether all posts to be selected by default, true if omitted
                 * @returns 
                 */
                show({ posts, select_all = true }) {
                    if (this.showing) { return this.#promise; }
                    ({ promise: this.#promise, resolve: this.#resolve } = Promise.withResolvers());

                    this.posts = posts;
                    this.instance.resolve = this.#resolve;
                    this.instance.posts = posts;
                    this.instance.selectAll(select_all);
                    this.instance.show = true;
                    return this.#promise;
                }
            }

            const selector = new PostsSelector();

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

            // 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
             */
            /**
             * @typedef {Object} kemono_file
             * @property {string} path
             * @property {string} [name]
             * @property {string} [server]
             */
            /**
             * one-time download config for current download
             * @typedef {Object} download_config
             * @property {boolean} [flatten_files] - put attachments into root folder instead of attachments folder
             * @property {boolean} [no_prefix] - do not add filename prefixes
             * @property {boolean} [number_prefix] - use number as filename prefix for main file too
             * @property {string[]} [ext_filter] - download files with these extensions only
             */

            /**
             * Download one post in zip file, and show ProgressBoard
             * @param {Object} detail
             * @param {string} detail.service
             * @param {number | string} detail.creator_id
             * @param {number | string} detail.post_id
             * @param {download_config} [detail.config] - config object passing to fetchPost
             */
            async function downloadPost({
                post,
                config = {}
            } = {}) {
                if (!checkFSAPISaveDir(config)) { return; }
                const { service, creator_id, post_id } = post;
                const manager = new utils.ProgressManager(3, {
                    type: 'root',
                    name: CONST.Text.ProgressBoard.TotalProgress
                });
                gui.board.show(manager);

                const data = await manager.progress(API.Posts.post(service, creator_id, post_id));
                const folder = await manager.progress(fetchPost({ api_data: data, manager, config }));
                const zip = folder.zip();
                const filename = `${data.post.title}.zip`;
                manager.progress(await saveAs(filename, zip, manager, null, config));
            }

            /**
             * @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 {Object} detail
             * @param {PostInfo | PostInfo[]} detail.posts
             * @param {string} detail.filename - file name of final zip file to be delivered to the user
             * @param {download_config} [detail.config] - config object passing to fetchPost
             */
            async function downloadPosts({
                posts, filename,
                config = {}
            } = {}) {
                if (!checkFSAPISaveDir(config)) { return; }
                Array.isArray(posts) || (posts = [posts]);
                const manager = new utils.ProgressManager(posts.length + 1, {
                    type: 'root',
                    name: CONST.Text.ProgressBoard.TotalProgress,
                    desc: replaceText(CONST.Text.ProgressBoard.TotalProgDesc, {
                        '{PostsCount}': posts.length.toString()
                    })
                });
                gui.board.show(manager);

                // Fetch posts
                const post_folders = await Promise.all(
                    posts.map(async post => {
                        const data = await API.Posts.post(post.service, post.creator_id, post.post_id);
                        const folder = await fetchPost({ api_data: data, manager, config });
                        await manager.progress();
                        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, null, config));
            }

            /**
             * Fetch one post
             * @param {Object} detail
             * @param {Object} detail.api_data - kemono post api returned data object
             * @param {utils.ProgressManager} detail.manager
             * @param {download_config} [detail.config] - config used for this download, default value for omitted ones is from settings
             * @returns {Promise<DownloaderFolder>}
             */
            async function fetchPost({ api_data, manager, config }) {
                const perfmon_run = perfmon.run('fetchPost', api_data);
                const sub_manager = manager.sub(0, {
                    type: 'folder',
                    name: api_data.post.title,
                    desc: replaceText(CONST.Text.ProgressBoard.PostProgDesc, {
                        '{FilesCount}': api_data.post.attachments.length + (api_data.post.file.path ? 1 : 0)
                    })
                });

                let { save_apijson, save_content, flatten_files, no_prefix, ext_filter, number_prefix } = config;
                save_apijson = save_apijson !== undefined ? save_apijson : settings.save_apijson;
                save_content = save_content !== undefined ? save_content : settings.save_content;
                flatten_files = flatten_files !== undefined ? flatten_files : settings.flatten_files;
                no_prefix = no_prefix !== undefined ? no_prefix : settings.no_prefix;
                number_prefix = number_prefix !== undefined ? number_prefix : settings.number_prefix;

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

                // parent folder for attachments
                let attachments_folder = folder;
                if (api_data.post.attachments.length && !flatten_files) {
                    attachments_folder = new DownloaderFolder('attachments');
                    folder.children.push(attachments_folder);
                }

                /**
                 * @typedef {Object} file_obj
                 * @property {'main' | 'attachment'} type
                 * @property {kemono_file} file
                 * @property {number} index - zero-based index of file in its parent folder's all numbered files
                 */
                /** @type {file_obj[]} */
                const file_objs = [
                    {
                        type: 'main', 
                        file: api_data.post.file,
                        index: number_prefix ? 0 : -1
                    },
                    ...api_data.post.attachments.map((attachment, i) => ({
                        type: 'attachment',
                        file: attachment,
                        index: (flatten_files && number_prefix) ? i + 1 : i
                    }))
                ];
                const tasks = file_objs.map(async ({ type: file_type, file, index }) => {
                    if (!file.path) { return; }
                    const ext = getFileExt(file);
                    if (ext_filter && ext_filter.length && !ext_filter.includes(ext)) { return; }

                    sub_manager.add();

                    // Filename prefix
                    const file_number = index + 1;
                    const file_number_prefix = `${ utils.fillNumber(file_number, file_objs.length.toString().length) }-`;
                    const prefix = ({
                        main: no_prefix ? '' : (number_prefix ? file_number_prefix : 'file-'),
                        attachment: no_prefix ? '' : file_number_prefix
                    })[file_type];

                    // Filename
                    const ori_name = getFileName(file);
                    const name = escapePath(prefix + ori_name);

                    // Fetch blob
                    const url = getFileUrl(file);
                    const blob = await sub_manager.progress(fetchBlob(url, sub_manager));

                    // Add to folder
                    if (blob) {
                        const parent_folder = ({
                            main: folder,
                            attachment: attachments_folder
                        })[file_type];
                        parent_folder.children.push(new DownloaderFile(
                            name, blob, {
                                date,
                                comment: JSON.stringify(file)
                            }
                        ));
                    }
                });
                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;

                /**
                 * @param {kemono_file} file
                 * @returns {string}
                 */
                function getFileName(file) {
                    return file.name || file.path.slice(file.path.lastIndexOf('/')+1);
                }

                /**
                 * @param {kemono_file} file 
                 * @returns {string}
                 */
                function getFileUrl(file) {
                    return (getFileServer(file) ?? 'https://n1.kemono.su') + '/data' + file.path;
                }

                /**
                 * @param {kemono_file} file 
                 * @returns {string} file extension name, not including '.'
                 */
                function getFileExt(file) {
                    const name = getFileName(file);
                    return name.slice(name.lastIndexOf('.') + 1);
                }

                /**
                 * @param {kemono_file} file 
                 * @returns {string} file extension name, not including '.'
                 */
                function getFileServer(file) {
                    if (file.server) {
                        return file.server;
                    }

                    const preview = [...api_data.attachments, ...api_data.previews].find(preview => preview.path === file.path);
                    return preview?.server ?? null;
                }
            }
            /**
             * Deliver zip file to user
             * @param {string} filename - filename (with extension, e.g. "file.zip")
             * @param {JSZip} zip,
             * @param {ProgressManager} manager
             * @param {string | null} [comment] - zip file comment
             * @param {download_config} [config] - config used for this download, default value for omitted ones is from settings
             */
            async function saveAs(filename, zip, manager, comment=null, config = {}) {
                const perfmon_run = perfmon.run('saveAs', filename);
                const sub_manager = manager.sub(100, {
                    type: 'zip',
                    name: filename,
                    desc: CONST.Text.ProgressBoard.ZipProgDesc
                });

                if (settings.fileapi) {
                    // FileSystemAPI
                    if (!checkFSAPISaveDir(config)) { return; }
                    const dir_handle = config.savedir ?? settings.savedir;

                    // Get existing file/folder names in savedir
                    const existing_fnames = [];
                    for await (const f of dir_handle.values()) {
                        existing_fnames.push(f.name);
                    }

                    // Generate filename
                    if (existing_fnames.includes(filename)) {
                        const ext_index = filename.lastIndexOf('.');
                        const base = filename.slice(0, ext_index);
                        const ext = filename.slice(ext_index + 1);
                        let new_fname = filename;
                        for (let i = 2; existing_fnames.includes(new_fname); i++) {
                            new_fname = `${ base } (${ i }).${ ext }`;
                        }
                        filename = new_fname;
                    }

                    // Create file
                    const file_handle = await dir_handle.getFileHandle(filename, { create: true });
                    const stream = await file_handle.createWritable({ mode: 'exclusive' });

                    // Generate zip file stream and write into file
                    await new Promise((resolve, reject) => {
                        const options = { type: 'uint8array' };
                        zip.generateInternalStream(
                            options
                        ).on('data', (data, metadata) => {
                            stream.write(data);
                            sub_manager.progress(null, metadata.percent)
                        }).on('end', () => {
                            stream.close();
                            resolve();
                        }).resume();
                    });
                } else {
                    // Traditional <a download>
                    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 \
             * resolves null if error thrown
             * Queued function of _fetchBlob
             * @param {string} url
             * @param {utils.ProgressManager} [manager]
             * @returns {Promise<Blob | null>}
             */
            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
             * resolves null if error thrown
             * @param {string} url
             * @param {utils.ProgressManager} [manager]
             * @param {utils.ProgressManager} [sub_manager] provided only in error-retry logic
             * @param {number} [retry=3] - times to retry before throwing an error
             * @returns {Promise<Blob | null>}
             */
            async function _fetchBlob(url, manager, sub_manager = null, retry = 3) {
                const perfmon_run = perfmon.run('_fetchBlob', url);
                sub_manager = sub_manager ?? manager.sub(0, {
                    type: 'blob',
                    name: url.slice(url.lastIndexOf('/') + 1),
                    desc: url
                });

                try {
                    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) {
                                sub_manager.reset();
                                _fetchBlob(url, manager, sub_manager, retry - 1).then(result => {
                                    perfmon_run.stop();
                                    resolve(result);
                                }).catch(err => doReject(err));
                            } else {
                                doReject(err);
                            }
        
                            function doReject(err) {
                                sub_manager.newError('self');
                                perfmon_run.stop();
                                //reject(err);
                                resolve(null);
                            }
                        }
                    });
                    return blob;
                } catch(err) {
                    throw err;
                } finally {
                    perfmon_run.stop();
                }
            }

            /**
             * Check if FileSystemAPI is enabled but SaveDir not set
             * If not, alert user and return false
             * @param {download_config} - also checks this config for savedir existance
             * @returns {boolean}
             */
            function checkFSAPISaveDir(config) {
                if (settings.fileapi && !settings.savedir && !config.savedir) {
                    Quasar.Dialog.create({
                        title: CONST.Text.SaveDirMissingTitle,
                        message: CONST.Text.SaveDirMissing
                    });
                    return false;
                }
                return true;
            }

            /**
             * 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: ['utils', 'dependencies'],
        params: ['GM_setValue', 'GM_getValue'],
        async func(GM_setValue, GM_getValue) {
            const utils = require('utils');

            // settings 数据,所有设置项在这里配置
            /** @type {setting[]} */
            const settings_data = CONST.Settings;

            // settings 读/写对象,既用于oFunc内读写,也直接作为oFunc返回值提供给其他功能函数
            // 对于提供了storage键的,默认直接在GM存储空间读写;不提供storage键的,仅在内存中读写
            const settings = {};
            settings_data.forEach(setting => {
                if (setting.storage) {
                    Object.defineProperty(settings, setting.key, {
                        get() {
                            return GM_getValue(setting.storage, setting.default);
                        },
                        set(val) {
                            GM_setValue(setting.storage, !!val);
                        }
                    });
                } else {
                    settings[setting.key] = setting.default;
                }
            });

            // 创建设置界面
            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.Title }</div>
                            </q-card-section>
                            <q-card-section style="max-height: 75vh;" class="scroll">
                                <q-list>
                                    <q-item tag="label" v-for="setting of settings_data" v-ripple>
                                        <q-item-section>
                                            <q-item-label>{{ setting.title }}</q-item-label>
                                            <q-item-label caption v-if="setting.caption">{{ setting.caption }}</q-item-label>
                                        </q-item-section>
                                        <q-item-section avatar>
                                            <q-toggle v-if="setting.type === 'boolean'" color="primary" v-model="settings[setting.key]"></q-toggle>
                                            <div v-else-if="setting.type === 'folder'">
                                                <span style="margin-right: 0.5em;">{{ settings[setting.key]?.name ?? ${ escJsStr(CONST.Text.Settings.NoFolderSelected) } }}</span>
                                                <q-btn icon="folder_open" color="primary" @click="requestFolder(setting.key)"></q-button>
                                            </div>
                                        </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,
                        settings_data,
                        settings
                    };
                },
                methods: {
                    /**
                     * @param {string} key
                     */
                    async requestFolder(key) {
                        /** @type {Window} */
                        const win = utils.window;
                        if (!win.showDirectoryPicker) {
                            Quasar.Dialog.create({
                                title: CONST.Text.Settings.FileSystemAPINotSupportedTitle,
                                message: CONST.Text.Settings.FileSystemAPINotSupported,
                            });
                            return;
                        }
                        const dir_handle = await win.showDirectoryPicker({
                            id: key,
                            mode: 'readwrite',
                            startIn: 'downloads'
                        });
                        this.settings[key] = dir_handle;
                    },
                },
                mounted() {
                    GM_registerMenuCommand(CONST.Text.Settings.Title, 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);

            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, downloadCustom, userDownload, getPageType
            };

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

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

            // 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;
                    await downloader.downloadPost({
                        post: {
                            service: post_info.service,
                            creator_id: post_info.creator_id,
                            post_id: post_info.post_id,
                        },
                    });
                } finally {
                    downloading = false;
                    dltext.innerText = CONST.Text.DownloadZip;
                }
            }

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

                try {
                    downloading = true;
                    Quasar.Loading.show({ message: CONST.Text.FetchingCreatorPosts });
                    const [profile, selected_posts] = await Promise.all([
                        api.Creators.profile(creator_info.service, creator_info.creator_id),
                        selectCreatorPosts(creator_info, () => Quasar.Loading.hide())
                    ]);
                    if (selected_posts) {
                        dlprogress.style.display = 'inline';
                        dltext.innerText = CONST.Text.Downloading;
                        await downloader.downloadPosts({
                            posts: selected_posts.map(post => ({
                                service: creator_info.service,
                                creator_id: creator_info.creator_id,
                                post_id: post.id
                            })),
                            filename: `${profile.name}.zip`
                        });
                    }
                } finally {
                    downloading = false;
                    dltext.innerText = CONST.Text.DownloadZip;
                }
            }

            async function downloadCustom() {
                if (downloading) { return; }

                try {
                    const post_info = getCurrentPost();
                    const creator_info = getCurrentCreator();
                    const info = post_info ?? creator_info ?? {};
                    const dl_info = await gui.panel.show({
                        download_type: post_info ? 'post' : (creator_info ? 'creator' : undefined),
                        service: info.service ?? undefined,
                        creator_id: info.creator_id ?? undefined,
                        post_id: info.post_id ?? undefined,
                        autoclose: false,
                    });
                    if (dl_info !== null) {
                        downloading = true;

                        if (dl_info.download_type === 'post') {
                            gui.panel.close();
                            await downloader.downloadPost({
                                post: {
                                    service: dl_info.service,
                                    creator_id: dl_info.creator_id,
                                    post_id: dl_info.post_id,
                                },
                                config: {
                                    ext_filter: dl_info.ext_filter,
                                    ...dl_info.settings,
                                }
                            });
                        } else if (dl_info.download_type === 'creator') {
                            gui.panel.loading = true;
                            Assert(creator_info !== null, 'dl_info.download_type === "creator" but creator_info is null', TypeError);
                            const [profile, selected_posts] = await Promise.all([
                                api.Creators.profile(creator_info.service, creator_info.creator_id),
                                selectCreatorPosts(creator_info, () => {
                                    gui.panel.loading = false;
                                    gui.panel.close();
                                })
                            ]);
                            if (selected_posts) {
                                dlprogress.style.display = 'inline';
                                dltext.innerText = CONST.Text.Downloading;
                                await downloader.downloadPosts({
                                    posts: selected_posts.map(post => ({
                                        service: creator_info.service,
                                        creator_id: creator_info.creator_id,
                                        post_id: post.id,
                                    })),
                                    config: {
                                        ext_filter: dl_info.ext_filter,
                                        ...dl_info.settings,
                                    },
                                    filename: `${profile.name}.zip`
                                });
                            }
                        }
                    }
                } finally {
                    downloading = false;
                    dltext.innerText = CONST.Text.DownloadZip;
                }
            }

            /**
             * User select creator's posts
             * @param {CreatorInfo} creator_info
             * @param {function} onAjaxLoad - callback when api ajax loaded
             * @returns 
             */
            async function selectCreatorPosts(creator_info, onAjaxLoad=function() {}) {
                const posts = await api.Custom.all_posts(creator_info.service, creator_info.creator_id);
                typeof onAjaxLoad === 'function' && onAjaxLoad();
                const selected_posts = await gui.selector.show({ posts });
                return selected_posts;
            }

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