- // ==UserScript==
- // @name Kemono zip download
- // @name:zh-CN Kemono 下载为ZIP文件
- // @namespace https://greasyfork.org/users/667968-pyudng
- // @version 0.10
- // @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/1546794/Basic%20Functions%20%28For%20userscripts%29.js
- // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
- // @require https://update.greasyfork.org/scripts/458132/1138364/ItemSelector.js
- // @resource vue-js https://unpkg.com/vue@3.5.13/dist/vue.global.prod.js
- // @resource quasar-icon https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons
- // @resource quasar-css https://cdn.jsdelivr.net/npm/quasar@2.15.1/dist/quasar.prod.css
- // @resource quasar-js https://cdn.jsdelivr.net/npm/quasar@2.15.1/dist/quasar.umd.prod.js
- // @connect kemono.su
- // @connect kemono.party
- // @icon https://kemono.su/favicon.ico
- // @grant GM_xmlhttpRequest
- // @grant GM_registerMenuCommand
- // @grant GM_addElement
- // @grant GM_getResourceText
- // @grant GM_setValue
- // @grant GM_getValue
- // @grant GM_addValueChangeListener
- // @run-at document-start
- // ==/UserScript==
-
- /* eslint-disable no-multi-spaces */
- /* eslint-disable no-return-assign */
-
- /* global LogLevel DoLog Err Assert $ $All $CrE $AEL $$CrE addStyle detectDom destroyEvent copyProp copyProps parseArgs escJsStr replaceText getUrlArgv dl_browser dl_GM AsyncManager queueTask FunctionLoader loadFuncs require isLoaded */
- /* global setImmediate JSZip ItemSelector Vue Quasar */
-
- (function __MAIN__() {
- 'use strict';
-
- const CONST = {
- TextAllLang: {
- DEFAULT: 'en-US',
- 'zh-CN': {
- DownloadZip: '下载为ZIP文件',
- DownloadPost: '下载当前作品为ZIP文件',
- DownloadCreator: '下载当前创作者为ZIP文件',
- Downloading: '正在下载...',
- SelectAll: '全选',
- TotalProgress: '总进度',
- ProgressBoardTitle: '下载进度',
- Settings: '设置',
- SaveContent: '保存文字内容到html文件',
- SaveApijson: '保存api结果到json文件',
- NoPrefix: '保存文件时不添加文件名前缀',
- FlattenFiles: '保存主文件和附件到同一级文件夹',
- FlattenFilesCaption: '主文件一般是封面图',
- },
- 'en-US': {
- DownloadZip: 'Download as ZIP file',
- DownloadPost: 'Download post as ZIP file',
- DownloadCreator: 'Download creator as ZIP file',
- Downloading: 'Downloading...',
- SelectAll: 'Select All',
- TotalProgress: 'Total Progress',
- ProgressBoardTitle: 'Download Progress',
- Settings: 'Settings',
- SaveContent: 'Save text content',
- SaveApijson: 'Save api result',
- NoPrefix: 'Do not add filename prefix',
- FlattenFiles: 'Save main file and attachments to same folder',
- FlattenFilesCaption: '"Main file" is usually the cover image',
- }
- }
- };
-
- // Init language
- const i18n = Object.keys(CONST.TextAllLang).includes(navigator.language) ? navigator.language : CONST.TextAllLang.DEFAULT;
- CONST.Text = CONST.TextAllLang[i18n];
-
- loadFuncs([{
- id: 'utils',
- async func() {
- const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
-
- function fillNumber(number, length) {
- const str = number.toString();
- return '0'.repeat(length - str.length) + str;
- }
-
- /**
- * Async task progress manager \
- * when awaiting async tasks, replace `await task` with `await manager.progress(task)` \
- * suppoerts sub-tasks, just `manager.sub(sub_steps, sub_callback)`
- */
- class ProgressManager {
- /** @type {*} */
- info;
- /** @type {number} */
- steps;
- /** @type {progressCallback} */
- callback;
- /** @type {number} */
- finished;
- /** @type {ProgressManager[]} */
- #children;
- /** @type {ProgressManager} */
- #parent;
-
- /**
- * This callback is called each time a promise resolves
- * @callback progressCallback
- * @param {number} resolved_count
- * @param {number} total_count
- * @param {ProgressManager} manager
- */
-
- /**
- * @param {number} [steps=0] - total steps count of the task
- * @param {progressCallback} [callback] - callback each time progress updates
- * @param {*} [info] - attach any data about this manager if need
- */
- constructor(steps=0, callback=function() {}, info=undefined) {
- this.steps = steps;
- this.callback = callback;
- this.info = info;
- this.finished = 0;
-
- this.#children = [];
- this.#callCallback();
- }
-
- add() { this.steps++; }
-
- /**
- * @template {Promise | null} task
- * @param {task} [promise] - task to await, null is acceptable if no task to await
- * @param {number} [finished] - set this.finished to this value, defaults to this.finished+1
- * @returns {Awaited<task>}
- */
- async progress(promise, finished) {
- const val = await Promise.resolve(promise);
- try {
- this.finished = typeof finished === 'number' ? finished : this.finished + 1;
- this.#callCallback();
- //this.finished === this.steps && this.#parent && this.#parent.progress();
- } finally {
- return val;
- }
- }
-
- /**
- * Creates a new ProgressManager as a sub-progress of this
- * @param {number} [steps=0] - total steps count of the task
- * @param {progressCallback} [callback] - callback each time progress updates, defaulting to parent.callback
- * @param {*} [info] - attach any data about the sub-manager if need
- */
- sub(steps, callback, info) {
- const manager = new ProgressManager(steps ?? 0, callback ?? this.callback, info);
- manager.#parent = this;
- this.#children.push(manager);
- return manager;
- }
-
- #callCallback() {
- this.callback(this.finished, this.steps, this);
- }
-
- get children() {
- return [...this.#children];
- }
-
- get parent() {
- return this.#parent;
- }
-
- /**
- * Resolves after all promise resolved, and callback each time one of them resolves
- * @param {Array<Promise>} promises
- * @param {progressCallback} callback
- */
- static async all(promises, callback) {
- const manager = new ProgressManager(promises.length, callback);
- await Promise.all(promises.map(promise => manager.progress(promise, callback)));
- }
- }
-
- const PerformanceManager = (function() {
- class RunRecord {
- static #id = 0;
-
- /** @typedef {'initialized' | 'running' | 'finished'} run_status */
- /** @type {number} */
- id;
- /** @type {number} */
- start;
- /** @type {number} */
- end;
- /** @type {number} */
- duration;
- /** @type {run_status} */
- status;
- /**
- * Anything for programmers to mark and read, uses as a description for this run
- * @type {*}
- */
- info;
-
- /**
- * @param {*} [info] - Anything for programmers to mark and read, uses as a description for this run
- */
- constructor(info) {
- this.id = RunRecord.#id++;
- this.status = 'initialized';
- this.info = info;
- }
-
- run() {
- const time = performance.now();
- this.start = time;
- this.status = 'running';
- return this;
- }
-
- stop() {
- const time = performance.now();
- this.end = time;
- this.duration = this.end - this.start;
- this.status = 'finished';
- return this;
- }
- }
- class Task {
- /** @typedef {number | string | symbol} task_id */
- /** @type {task_id} */
- id;
- /** @type {RunRecord[]} */
- runs;
-
- /**
- * @param {task_id} id
- */
- constructor(id) {
- this.id = id;
- this.runs = [];
- }
-
- run(info) {
- const record = new RunRecord(info);
- record.run();
- this.runs.push(record);
- return record;
- }
-
- get time() {
- return this.runs.reduce((time, record) => {
- if (record.status === 'finished') {
- time += record.duration;
- }
- return time;
- }, 0)
- }
- }
- class PerformanceManager {
- /** @type {Task[]} */
- tasks;
-
- constructor() {
- this.tasks = [];
- }
-
- /**
- * @param {task_id} id
- * @returns {Task | null}
- */
- getTask(id) {
- return this.tasks.find(task => task.id === id);
- }
-
- /**
- * Creates a new task
- * @param {task_id} id
- * @returns {Task}
- */
- newTask(id) {
- Assert(!this.getTask(id), `given task id ${escJsStr(id)} is already in use`, TypeError);
-
- const task = new Task(id);
- this.tasks.push(task);
- return task;
- }
-
- /**
- * Runs a task
- * @param {task_id} id
- * @param {*} run_info - Anything for programmers to mark and read, uses as a description for this run
- * @returns {RunRecord}
- */
- run(task_id, run_info) {
- const task = this.getTask(task_id);
- Assert(task, `task of id ${escJsStr(task_id)} not found`, TypeError);
-
- return task.run(run_info);
- }
-
- totalTime(id) {
- if (id) {
- return this.getTask(id).time;
- } else {
- return this.tasks.reduce((timetable, task) => {
- timetable[task.id] = task.time;
- return timetable;
- }, {});
- }
- }
-
- meanTime(id) {
- if (id) {
- const task = this.getTask(id);
- return task.time / task.runs.length;
- } else {
- return this.tasks.reduce((timetable, task) => {
- timetable[task.id] = task.time / task.runs.length;
- return timetable;
- }, {});
- }
- }
- }
-
- return PerformanceManager;
- }) ();
-
- return {
- window: win,
- fillNumber, ProgressManager, PerformanceManager
- }
- }
- }, {
- id: 'dependencies',
- desc: 'load dependencies like vue into the page',
- detectDom: ['head', 'body'],
- async func() {
- const deps = [{
- name: 'vue-js',
- type: 'script',
- }, {
- name: 'quasar-icon',
- type: 'style'
- }, {
- name: 'quasar-css',
- type: 'style'
- }, {
- name: 'quasar-js',
- type: 'script'
- }];
-
- await Promise.all(deps.map(dep => {
- return new Promise((resolve, reject) => {
- switch (dep.type) {
- case 'script': {
- // Once load, dispatch load event on messager
- const evt_name = `load:${dep.name};${Date.now()}`;
- const rand = Math.random().toString();
- const messager = new EventTarget();
- const load_code = [
- '\n;',
- `window[${escJsStr(rand)}].dispatchEvent(new Event(${escJsStr(evt_name)}));`,
- `delete window[${escJsStr(rand)}];\n`
- ].join('\n');
- unsafeWindow[rand] = messager;
- $AEL(messager, evt_name, resolve);
- GM_addElement(document.head, 'script', {
- textContent: GM_getResourceText(dep.name) + load_code,
- });
- break;
- }
- case 'style': {
- GM_addElement(document.head, 'style', {
- textContent: GM_getResourceText(dep.name),
- });
- resolve();
- break;
- }
- }
- });
- }));
- }
- }, {
- id: 'api',
- desc: 'api for kemono',
- async func() {
- let api_key = null;
-
- const Posts = {
- /**
- * Get a list of creator posts
- * @param {string} service - The service where the post is located
- * @param {number | string} creator_id - The ID of the creator
- * @param {string} [q] - Search query
- * @param {number} [o] - Result offset, stepping of 50 is enforced
- */
- posts(service, creator_id, q, o) {
- const search = {};
- q && (search.q = q);
- o && (search.o = o);
- return callApi({
- endpoint: `/${service}/user/${creator_id}`,
- search
- });
- },
-
- /**
- * Get a specific post
- * @param {string} service
- * @param {number | string} creator_id
- * @param {number | string} post_id
- * @returns {Promise<Object>}
- */
- post(service, creator_id, post_id) {
- return callApi({
- endpoint: `/${service}/user/${creator_id}/post/${post_id}`
- });
- }
- };
- const Creators = {
- /**
- * Get a creator
- * @param {string} service - The service where the creator is located
- * @param {number | string} creator_id - The ID of the creator
- * @returns
- */
- profile(service, creator_id) {
- return callApi({
- endpoint: `/${service}/user/${creator_id}/profile`
- });
- }
- };
- const Custom = {
- /**
- * Get a list of creator's ALL posts, calling Post.posts for multiple times and joins the results
- * @param {string} service - The service where the post is located
- * @param {number | string} creator_id - The ID of the creator
- * @param {string} [q] - Search query
- */
- async all_posts(service, creator_id, q) {
- const posts = [];
- let offset = 0;
- let api_result = null;
- while (!api_result || api_result.length === 50) {
- api_result = await Posts.posts(service, creator_id, q, offset);
- posts.push(...api_result);
- offset += 50;
- }
- return posts;
- }
- };
- const API = {
- get key() { return api_key; },
- set key(val) { api_key = val; },
- Posts, Creators,
- Custom,
- callApi
- };
- return API;
-
- /**
- * callApi detail object
- * @typedef {Object} api_detail
- * @property {string} endpoint - api endpoint
- * @property {Object} [search] - search params
- * @property {string} [method='GET']
- * @property {boolean | string} [auth=false] - whether to use api-key in this request; true for send, false for not, and string for sending a specific key (instead of pre-configured `api_key`); defaults to false
- */
-
- /**
- * Do basic kemono api request
- * This is the queued version of _callApi
- * @param {api_detail} detail
- * @returns
- */
- function callApi(...args) {
- return queueTask(() => _callApi(...args), 'callApi');
- }
-
- /**
- * Do basic kemono api request
- * @param {api_detail} detail
- * @returns
- */
- function _callApi(detail) {
- const search_string = new URLSearchParams(detail.search).toString();
- const url = `https://kemono.su/api/v1/${detail.endpoint.replace(/^\//, '')}` + (search_string ? '?' + search_string : '');
- const method = detail.method ?? 'GET';
- const auth = detail.auth ?? false;
-
- return new Promise((resolve, reject) => {
- auth && api_key === null && reject('api key not found');
-
- const options = {
- method, url,
- headers: {
- accept: 'application/json'
- },
- onload(e) {
- try {
- e.status === 200 ? resolve(JSON.parse(e.responseText)) : reject(e.responseText);
- } catch(err) {
- reject(err);
- }
- },
- onerror: err => reject(err)
- }
- if (typeof auth === 'string') {
- options.headers.Cookie = auth;
- } else if (auth === true) {
- options.headers.Cookie = api_key;
- }
- GM_xmlhttpRequest(options);
- });
- }
- }
- }, {
- id: 'gui',
- desc: 'reusable GUI components',
- dependencies: 'dependencies',
- async func() {
- class ProgressBoard {
- /** @type {Vue} */
- app;
-
- /** Vue component instance */
- instance;
-
- /** @typedef {{ finished: number, total: number }} progress */
- /** @typedef {{ name: string, progress: progress }} item */
- /** @type {item[]} */
- items;
-
- constructor() {
- const that = this;
- this.items = [];
-
- // GUI
- const container = $$CrE({ tagName: 'div', styles: { position: 'fixed', zIndex: '-1' } });
- const board = $$CrE({
- tagName: 'div',
- classes: 'board'
- });
- board.innerHTML = `
- <q-layout view="hhh lpr fff">
- <q-dialog v-model="show" :class="{ mobile: $q.platform.is.mobile }" :position="$q.platform.is.mobile ? 'standard' : 'bottom'" seamless :full-width="$q.platform.is.mobile" :full-height="$q.platform.is.mobile">
- <q-card class="container-card">
- <q-card-section class="header row">
- <div class="text-h6">${ CONST.Text.ProgressBoardTitle }</div>
- <q-space></q-space>
- <q-btn :icon="minimized ? 'expand_less' : 'expand_more'" flat round dense @click="minimized = !minimized"></q-btn>
- <q-btn icon="close" flat round dense v-close-popup></q-btn>
- </q-card-section>
- <q-slide-transition>
- <q-card-section class="body" v-show="!minimized">
- <q-list class="list" :class="{ minimized: minimized }">
- <q-item tag="div" v-for="item of items">
- <q-item-section>
- <q-item-label>{{ item.name }}</q-item-label>
- </q-item-section>
- <q-item-section>
- <q-linear-progress animation-speed="500" :indeterminate="item.progress.total === 0" :value="item.progress.total > 0 ? item.progress.finished / item.progress.total : 0" :color="item.progress.total > 0 && item.progress.total === item.progress.finished ? 'green' : 'blue'"></q-linear-progress>
- </q-item-section>
- </q-item>
- </q-list>
- </q-card-section>
- </q-slide-transition>
- </q-card>
- </q-dialog>
- </q-layout>
- `;
- container.append(board);
- detectDom('html').then(html => html.append(container));
-
- detectDom('head').then(head => addStyle(`
- :is(.container-card):not(.mobile *) {
- max-width: 45vw;
- }
- :is(.header, .body):not(.mobile *) {
- width: 40vw;
- }
- :is(.body .list):not(.mobile *) {
- height: 40vh;
- overflow-y: auto;
- }
- :is(.body .list.minimized):not(.mobile *) {
- overflow-y: hidden;
- }
- `, 'progress-board-style'));
-
- this.app = Vue.createApp({
- data() {
- return {
- items: that.items,
- minimized: false,
- show: false
- }
- },
- methods: {
- debug() {
- debugger;
- }
- },
- mounted() {
- that.instance = this;
- }
- });
- this.app.use(Quasar);
- this.app.mount(board);
- Quasar.Dark.set(true);
- }
-
- /**
- * update item's progress display on progress_board
- * @param {string} name - must be unique among all items
- * @param {progress} progress
- */
- update(name, progress) {
- let item = this.instance.items.find(item => item.name === name);
- if (!item) {
- item = { name, progress };
- this.instance.items.push(item);
- }
- item.progress = progress;
- }
-
- /**
- * remove an item
- * @param {string} name
- */
- remove(name) {
- let item_index = this.instance.items.findIndex(item => item.name === name);
- if (item_index === -1) { return null; }
- return this.instance.items.splice(item_index, 1)[0];
- }
-
- /**
- * remove all existing items
- */
- clear() {
- this.instance.items.splice(0, this.instance.items.length);
- }
-
- get minimized() {
- return this.instance.minimized;
- }
-
- set minimized(val) {
- this.instance.minimized = !!val;
- }
-
- get closed() {
- return !this.instance.show;
- }
-
- set closed(val) {
- this.instance.show = !val;
- }
- }
-
- const board = new ProgressBoard();
-
- return { ProgressBoard, board };
- }
- }, {
- id: 'downloader',
- desc: 'core zip download utils',
- dependencies: ['utils', 'api', 'settings'],
- async func() {
- const utils = require('utils');
- const API = require('api');
- const settings = require('settings');
-
- // Performance record
- const perfmon = new utils.PerformanceManager();
- perfmon.newTask('fetchPost');
- perfmon.newTask('saveAs');
- perfmon.newTask('_fetchBlob');
-
-
- class DownloaderItem {
- /** @typedef {'file' | 'folder'} downloader_item_type */
- /**
- * Name of the item, CANNOT BE PATH
- * @type {string}
- */
- name;
- /** @type {downloader_item_type} */
- type;
-
- /**
- * @param {string} name
- * @param {downloader_item_type} type
- */
- constructor(name, type) {
- this.name = name;
- this.type = type;
- }
- }
-
- class DownloaderFile extends DownloaderItem{
- /** @type {Blob} */
- data;
- /** @type {Date} */
- date;
- /** @type {string} */
- comment;
-
- /**
- * @param {string} name - name only, CANNOT BE PATH
- * @param {Blob} data
- * @param {Object} detail
- * @property {Date} [date]
- * @property {string} [comment]
- */
- constructor(name, data, detail) {
- super(name, 'file');
- this.data = data;
- Object.assign(this, detail);
- }
-
- zip(jszip_instance) {
- const z = jszip_instance ?? new JSZip();
- const options = {};
- this.date && (options.date = this.date);
- this.comment && (options.comment = this.comment);
- z.file(this.name, this.data, options);
- return z;
- }
- }
-
- class DownloaderFolder extends DownloaderItem {
- /** @type {Array<DownloaderFile | DownloaderFolder>} */
- children;
-
- /**
- * @param {string} name - name only, CANNOT BE PATH
- * @param {Array<DownloaderFile | DownloaderFolder>} [children]
- */
- constructor(name, children) {
- super(name, 'folder');
- this.children = children && children.length ? children : [];
- }
-
- zip(jszip_instance) {
- const z = jszip_instance ?? new JSZip();
- for (const child of this.children) {
- switch (child.type) {
- case 'file': {
- child.zip(z);
- break;
- }
- case 'folder': {
- const sub_z = z.folder(child.name);
- child.zip(sub_z);
- }
- }
- }
- return z;
- }
- }
-
- /**
- * @typedef {Object} JSZip
- */
- /**
- * @typedef {Object} zipObject
- */
-
- /**
- * Download one post in zip file
- * @param {string} service
- * @param {number | string} creator_id
- * @param {number | string} post_id
- * @param {function} [callback] - called each time made some progress, with two numeric arguments: finished_steps and total_steps
- */
- async function downloadPost(service, creator_id, post_id, callback = function() {}) {
- const manager = new utils.ProgressManager(3, callback, {
- layer: 'root', service, creator_id, post_id
- });
- const data = await manager.progress(API.Posts.post(service, creator_id, post_id));
- const folder = await manager.progress(fetchPost(data, manager));
- const zip = folder.zip();
- const filename = `${data.post.title}.zip`;
- manager.progress(await saveAs(filename, zip, manager));
- }
-
- /**
- * @typedef {Object} PostInfo
- * @property {string} service,
- * @property {number} creator_id
- * @property {number} post_id
- */
-
- /**
- * Download one or multiple posts in zip file, one folder for each post
- * @param {PostInfo | PostInfo[]} posts
- * @param {string} filename - file name of final zip file to be delivered to the user
- * @param {*} callback - Progress callback, like downloadPost's callback param
- */
- async function downloadPosts(posts, filename, callback = function() {}) {
- Array.isArray(posts) || (posts = [posts]);
- const manager = new utils.ProgressManager(posts.length + 1, callback, {
- layer: 'root', posts, filename
- });
-
- // 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(data, manager);
- 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));
- }
-
- /**
- * Fetch one post
- * @param {Object} api_data - kemono post api returned data object
- * @param {utils.ProgressManager} [manager]
- * @returns {Promise<DownloaderFolder>}
- */
- async function fetchPost(api_data, manager) {
- const perfmon_run = perfmon.run('fetchPost', api_data);
- const sub_manager = manager.sub(0, manager.callback, {
- layer: 'post', data: api_data
- });
-
- const date = new Date(api_data.post.edited);
- const folder = new DownloaderFolder(escapePath(api_data.post.title), [
- ...settings.save_apijson ? [new DownloaderFile('data.json', JSON.stringify(api_data))] : [],
- ...settings.save_content ? [new DownloaderFile('content.html', api_data.post.content, { date })] : []
- ]);
-
- let attachments_folder = folder;
- if (api_data.post.attachments.length && !settings.flatten_files) {
- attachments_folder = new DownloaderFolder('attachments');
- folder.children.push(attachments_folder);
- }
- const tasks = [
- // api_data.post.file
- (async function(file) {
- if (!file.path) { return; }
- sub_manager.add();
- const prefix = settings.no_prefix ? '' : 'file-';
- const ori_name = getFileName(file);
- const name = escapePath(prefix + ori_name);
- const url = getFileUrl(file);
- const blob = await sub_manager.progress(fetchBlob(url, sub_manager));
- folder.children.push(new DownloaderFile(
- name, blob, {
- date,
- comment: JSON.stringify(file)
- }
- ));
- }) (api_data.post.file),
-
- // api_data.post.attachments
- ...api_data.post.attachments.map(async (attachment, i) => {
- if (!attachment.path) { return; }
- sub_manager.add();
- const prefix = settings.no_prefix ? '' : `${ utils.fillNumber(i+1, api_data.post.attachments.length.toString().length) }-`;
- const ori_name = getFileName(attachment);
- const name = escapePath(prefix + ori_name);
- const url = getFileUrl(attachment);
- const blob = await sub_manager.progress(fetchBlob(url, sub_manager));
- attachments_folder.children.push(new DownloaderFile(
- name, blob, {
- date,
- comment: JSON.stringify(attachment)
- }
- ));
- }),
- ];
- await Promise.all(tasks);
-
- // Make sure sub_manager finishes even when no async tasks
- if (sub_manager.steps === 0) {
- sub_manager.steps = 1;
- await sub_manager.progress();
- }
-
- perfmon_run.stop();
-
- return folder;
-
- function getFileName(file) {
- return file.name || file.path.slice(file.path.lastIndexOf('/')+1);
- }
-
- function getFileUrl(file) {
- return (file.server ?? 'https://n1.kemono.su') + '/data' + file.path;
- }
- }
-
- /**
- * Deliver zip file to user
- * @param {string} filename - filename (with extension, e.g. "file.zip")
- * @param {JSZip} zip,
- * @param {string | null} comment - zip file comment
- */
- async function saveAs(filename, zip, manager, comment=null) {
- const perfmon_run = perfmon.run('saveAs', filename);
- const sub_manager = manager.sub(100, manager.callback, {
- layer: 'zip', filename, zip
- });
-
- const options = { type: 'blob' };
- comment !== null && (options.comment = comment);
- const blob = await zip.generateAsync(options, metadata => sub_manager.progress(null, metadata.percent));
- const url = URL.createObjectURL(blob);
- const a = $$CrE({
- tagName: 'a',
- attrs: {
- download: filename,
- href: url
- }
- });
- a.click();
-
- perfmon_run.stop();
- }
-
- /**
- * Fetch blob data from given url \
- * Queued function of _fetchBlob
- * @param {string} url
- * @param {utils.ProgressManager} [manager]
- * @returns {Promise<Blob>}
- */
- function fetchBlob(...args) {
- if (!fetchBlob.initialized) {
- queueTask.fetchBlob = {
- max: 3,
- sleep: 0
- };
- fetchBlob.initialized = true;
- }
-
- return queueTask(() => _fetchBlob(...args), 'fetchBlob');
- }
-
- /**
- * Fetch blob data from given url
- * @param {string} url
- * @param {utils.ProgressManager} [manager]
- * @param {number} [retry=3] - times to retry before throwing an error
- * @returns {Promise<Blob>}
- */
- async function _fetchBlob(url, manager, retry = 3) {
- const perfmon_run = perfmon.run('_fetchBlob', url);
- const sub_manager = manager.sub(0, manager.callback, {
- layer: 'file', url
- });
-
- const blob = await new Promise((resolve, reject) => {
- GM_xmlhttpRequest({
- method: 'GET', url,
- responseType: 'blob',
- async onprogress(e) {
- sub_manager.steps = e.total;
- await sub_manager.progress(null, e.loaded);
- },
- onload(e) {
- e.status === 200 ? resolve(e.response) : onerror(e)
- },
- onerror
- });
-
- async function onerror(err) {
- if (retry) {
- await sub_manager.progress(null, -1);
- const result = await _fetchBlob(url, manager, retry - 1);
- resolve(result);
- } else {
- reject(err)
- }
- }
- });
-
- perfmon_run.stop();
-
- return blob;
- }
-
- /**
- * Replace unallowed special characters in a path part
- * @param {string} path - a part of path, such as a folder name / file name
- */
- function escapePath(path) {
- // Replace special characters
- const chars_bank = {
- '\\': '\',
- '/': '/',
- ':': ':',
- '*': '*',
- '?': '?',
- '"': "'",
- '<': '<',
- '>': '>',
- '|': '|'
- };
- for (const [char, replacement] of Object.entries(chars_bank)) {
- path = path.replaceAll(char, replacement);
- }
-
- // Disallow ending with dots
- path.endsWith('.') && (path += '_');
- return path;
- }
-
- return {
- downloadPost, downloadPosts,
- fetchPost, saveAs,
- perfmon
- };
- }
- }, {
- id: 'settings',
- detectDom: 'html',
- dependencies: 'dependencies',
- params: ['GM_setValue', 'GM_getValue'],
- async func(GM_setValue, GM_getValue) {
- // settings 数据,所有设置项在这里配置
- /**
- * @typedef {Object} setting
- * @property {string} title - 设置项名称
- * @property {string | null} [caption] - 设置项副标题,可以省略;为假值时不渲染副标题元素
- * @property {string} key - 用作settings 读/写对象的prop名称,也用作v-model值
- * @property {string} storage - GM存储key
- * @property {*} default - 用户未设置过时的默认值(初始值)
- */
- /** @type {setting[]} */
- const settings_data = [{
- title: CONST.Text.SaveContent,
- caption: 'content.html',
- key: 'save_content',
- storage: 'save_content',
- default: true,
- }, {
- title: CONST.Text.SaveApijson,
- caption: 'data.json',
- key: 'save_apijson',
- storage: 'save_apijson',
- default: true,
- }, {
- title: CONST.Text.NoPrefix,
- key: 'no_prefix',
- storage: 'no_prefix',
- default: false,
- }, {
- title: CONST.Text.FlattenFiles,
- caption: CONST.Text.FlattenFilesCaption,
- key: 'flatten_files',
- storage: 'flatten_files',
- default: false
- }];
-
- // settings 读/写对象,既用于oFunc内读写,也直接作为oFunc返回值提供给其他功能函数
- const settings = {};
- settings_data.forEach(setting => {
- Object.defineProperty(settings, setting.key, {
- get() {
- return GM_getValue(setting.storage, setting.default);
- },
- set(val) {
- GM_setValue(setting.storage, !!val);
- }
- });
- });
-
- // 创建设置界面
- const container = $$CrE({
- tagName: 'div',
- styles: { all: 'initial', position: 'fixed' }
- })
- const app_elm = $CrE('div');
- app_elm.innerHTML = `
- <q-layout view="hhh lpr fff">
- <q-dialog v-model="show">
- <q-card>
- <q-card-section>
- <div class="text-h6">${ CONST.Text.Settings }</div>
- </q-card-section>
- <q-card-section>
- <q-list>
- <q-item tag="label" v-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 color="orange" v-model="settings[setting.key]" @update:model-value="val => settings[setting.key] = val"></q-toggle>
- </q-item-section>
- </q-item>
- </q-list>
- </q-card-section>
- </q-card>
- </q-dialog>
- </q-layout>
- `;
- container.append(app_elm);
- $('html').append(container);
- const app = Vue.createApp({
- data() {
- return {
- show: false,
- settings_data,
- settings
- };
- },
- mounted() {
- GM_registerMenuCommand(CONST.Text.Settings, e => this.show = true);
- GM_addValueChangeListener('settings', () => {
- this.save_content = settings.save_content;
- this.save_apijson = settings.save_apijson;
- });
- }
- });
- app.use(Quasar);
- app.mount(app_elm);
- Quasar.Dark.set(true);
-
- return settings;
- }
- }, {
- id: 'user-interface',
- dependencies: ['utils', 'api', 'downloader', 'gui'],
- async func() {
- const utils = require('utils');
- const api = require('api');
- const downloader = require('downloader');
- const gui = require('gui');
-
- let downloading = false;
- let selector = null;
-
- // Console User Interface
- const ConsoleUI = utils.window.ZIP = {
- version: GM_info.script.version,
- require,
-
- api, downloader,
- get ui() { return require('user-interface') },
-
- downloadCurrentPost, downloadCurrentCreator, userDownload, getPageType
- };
-
- // Menu User Interface
- GM_registerMenuCommand(CONST.Text.DownloadPost, downloadCurrentPost);
- GM_registerMenuCommand(CONST.Text.DownloadCreator, downloadCurrentCreator);
-
- // Graphical User Interface
- // Make button
- const dlbtn = $$CrE({
- tagName: 'button',
- styles: {
- 'background-color': 'transparent',
- 'color': 'white',
- 'border': 'transparent'
- },
- listeners: [['click', userDownload]]
- });
- const dltext = $$CrE({
- tagName: 'span',
- props: {
- innerText: CONST.Text.DownloadZip
- }
- });
- const dlprogress = $$CrE({
- tagName: 'span',
- styles: {
- display: 'none',
- 'margin-left': '10px'
- }
- });
- dlbtn.append(dltext);
- dlbtn.append(dlprogress);
-
- const board = gui.board;
-
- // Place button each time a new action panel appears (meaning navigating into a post page)
- let observer;
- detectDom({
- selector: '.post__actions, .user-header__actions',
- callback: action_panel => {
- // Hide dlprogress, its content is still for previous page
- dlprogress.style.display = 'none';
-
- // Append to action panel
- action_panel.append(dlbtn);
-
- // Disconnect old observer
- observer?.disconnect();
-
- // Observe action panel content change, always put download button in last place
- observer = detectDom({
- root: action_panel,
- selector: 'button',
- callback: btn => btn !== dlbtn && action_panel.append(dlbtn)
- });
- }
- });
-
- return {
- ConsoleUI,
- dlbtn, dltext,
- get ['selector']() { return selector; },
- downloadCurrentPost, downloadCurrentCreator,
- getCurrentPost, getCurrentCreator, getPageType
- }
-
- function userDownload() {
- const page_type = getPageType();
- const func = ({
- post: downloadCurrentPost,
- creator: downloadCurrentCreator
- })[page_type] ?? function() {};
- return func();
- }
-
- async function downloadCurrentPost() {
- const post_info = getCurrentPost();
- if (downloading) { return; }
- if (!post_info) { return; }
-
- try {
- downloading = true;
- dlprogress.style.display = 'inline';
- dltext.innerText = CONST.Text.Downloading;
- board.minimized = false;
- board.closed = false;
- board.clear();
- await downloader.downloadPost(
- post_info.service,
- post_info.creator_id,
- post_info.post_id,
- on_progress
- );
- } finally {
- downloading = false;
- dltext.innerText = CONST.Text.DownloadZip;
- }
-
- function on_progress(finished_steps, total_steps, manager) {
- const info = manager.info;
- info.layer === 'root' && (dlprogress.innerText = `(${finished_steps}/${total_steps})`);
-
- const progress = { finished: finished_steps, total: total_steps };
- info.layer === 'root' && board.update(CONST.Text.TotalProgress, progress);
- info.layer === 'post' && board.update(info.data.post.title, progress);
- info.layer === 'file' && board.update(info.url, progress);
- info.layer === 'zip' && board.update(info.filename, progress);
- board.closed = false;
- }
- }
-
- async function downloadCurrentCreator() {
- const creator_info = getCurrentCreator();
- if (downloading) { return; }
- if (!creator_info) { return; }
-
- try {
- downloading = true;
- const profile = await api.Creators.profile(creator_info.service, creator_info.creator_id);
- const posts = await api.Custom.all_posts(creator_info.service, creator_info.creator_id);
- const selected_posts = await new Promise((resolve, reject) => {
- if (!selector) {
- selector = new ItemSelector();
- selector.setTheme('dark');
- selector.elements.container.style.setProperty('z-index', '1');
- }
- selector.show({
- text: CONST.Text.SelectAll,
- children: posts.map(post => ({
- post,
- text: post.title
- }))
- }, {
- title: CONST.Text.DownloadCreator,
- onok(e, json) {
- const posts = json.children.map(obj => obj.post);
- resolve(posts);
- },
- oncancel: e => resolve(null),
- onclose: e => resolve(null)
- });
- });
- if (selected_posts) {
- dlprogress.style.display = 'inline';
- dltext.innerText = CONST.Text.Downloading;
- board.minimized = false;
- board.closed = false;
- board.clear();
- await downloader.downloadPosts(selected_posts.map(post => ({
- service: creator_info.service,
- creator_id: creator_info.creator_id,
- post_id: post.id
- })), `${profile.name}.zip`, on_progress);
- }
- } finally {
- downloading = false;
- dltext.innerText = CONST.Text.DownloadZip;
- }
-
- function on_progress(finished_steps, total_steps, manager) {
- const info = manager.info;
- info.layer === 'root' && (dlprogress.innerText = `(${finished_steps}/${total_steps})`);
-
- const progress = { finished: finished_steps, total: total_steps };
- info.layer === 'root' && board.update(CONST.Text.TotalProgress, progress);
- info.layer === 'post' && board.update(info.data.post.title, progress);
- info.layer === 'file' && board.update(info.url, progress);
- info.layer === 'zip' && board.update(info.filename, progress);
- board.closed = false;
- }
- }
-
- /**
- * @typedef {Object} PostInfo
- * @property {string} service,
- * @property {number} creator_id
- * @property {number} post_id
- */
- /**
- * Get post info in current page
- * @returns {PostInfo | null}
- */
- function getCurrentPost() {
- const regpath = /^\/([a-zA-Z]+)\/user\/(\d+)\/post\/(\d+)/;
- const match = location.pathname.match(regpath);
- if (!match) {
- return null;
- } else {
- return {
- service: match[1],
- creator_id: parseInt(match[2], 10),
- post_id: parseInt(match[3], 10)
- }
- }
- }
-
- /**
- * @typedef {Object} CreatorInfo
- * @property {string} service
- * @property {number} creator_id
- */
- /**
- * Get creator info in current page
- * @returns {CreatorInfo | null}
- */
- function getCurrentCreator() {
- const regpath = /^\/([a-zA-Z]+)\/user\/(\d+)/;
- const match = location.pathname.match(regpath);
- if (!match) {
- return null;
- } else {
- return {
- service: match[1],
- creator_id: parseInt(match[2], 10)
- }
- }
- }
-
- /** @typedef { 'post' | 'creator' } page_type */
- /**
- * @returns {page_type}
- */
- function getPageType() {
- const matchers = {
- post: {
- type: 'regpath',
- value: /^\/([a-zA-Z]+)\/user\/(\d+)\/post\/(\d+)/
- },
- creator: {
- type: 'func',
- value: () => /^\/([a-zA-Z]+)\/user\/(\d+)/.test(location.pathname) && !location.pathname.includes('/post/')
- },
- }
- for (const [type, matcher] of Object.entries(matchers)) {
- if (FunctionLoader.testCheckers(matcher)) {
- return type;
- }
- }
- }
- },
- }]);
- }) ();