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