// ==UserScript==
// @name Kemono zip download
// @name:zh-CN Kemono 下载为ZIP文件
// @namespace https://greasyfork.org/users/667968-pyudng
// @version 0.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
// @connect kemono.su
// @connect kemono.party
// @icon https://kemono.su/favicon.ico
// @grant GM_xmlhttpRequest
// @grant GM_registerMenuCommand
// @run-at document-start
// ==/UserScript==
/* eslint-disable no-multi-spaces */
/* eslint-disable no-return-assign */
/* global LogLevel DoLog Err $ $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 */
(function __MAIN__() {
'use strict';
const CONST = {
TextAllLang: {
DEFAULT: 'en-US',
'zh-CN': {
DownloadZip: '下载为ZIP文件',
Downloading: '正在下载...'
},
'en-US': {
DownloadZip: 'Download as ZIP file',
Downloading: 'Downloading...'
}
}
};
// 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;
}
class ProgressManager {
/** @type {number} */
#steps;
/** @type {progressCallback} */
#callback;
/** @type {number} */
#finished;
/**
* This callback is called each time a promise resolves
* @callback progressCallback
* @param {number} resolved_count
* @param {number} total_count
*/
/**
* @param {number} steps - Total steps count of the task
*/
constructor(steps, callback) {
this.#steps = steps;
this.#callback = callback;
this.#finished = 0;
this.#callback(this.#finished, this.#steps);
}
async progress(promise) {
const val = await promise;
try {
this.#callback(++this.#finished, this.#steps);
} finally {
return val;
}
}
/**
* 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)));
}
}
return {
window: win,
fillNumber, ProgressManager
}
}
}, {
id: 'api',
desc: 'api for kemono',
async func() {
let api_key = null;
const Posts = {
/**
* 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({
url: `/${service}/user/${creator_id}/post/${post_id}`
});
}
};
const API = {
get key() { return api_key; },
set key(val) { api_key = val; },
Posts,
callApi
};
return API;
/**
* callApi detail object
* @typedef {Object} api_detail
* @property {string} url
* @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
* @param {api_detail} detail
* @returns
*/
function callApi(detail) {
const url = `https://kemono.su/api/v1/${detail.url.replace(/^\//, '')}`;
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: 'downloader',
desc: 'core zip download utils',
dependencies: ['utils', 'api'],
async func() {
const utils = require('utils');
const API = require('api');
return {
downloadPost,
zipPost, saveAs
};
/**
* @typedef {Object} JSZip
*/
/**
* 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);
const data = await manager.progress(API.Posts.post(service, creator_id, post_id));
const zip = await manager.progress(zipPost(data));
const filename = `${data.post.title}.zip`;
manager.progress(await saveAs(filename, zip));
}
/**
* Fetch one post in zip
* @param {Object} data - kemono post api returned data object
* @returns {JSZip}
*/
async function zipPost(data) {
const date = new Date(data.post.edited);
const zip = new JSZip();
zip.file('data.json', JSON.stringify(data));
zip.file('content.html', data.post.content, { date });
const tasks = [
// data.post.file
(async function(file) {
const name = `file-${getFileName(file)}`;
const url = 'https://n1.kemono.su/data' + file.path;
const blob = await fetchBlob(url);
zip.file(name, blob, {
date,
comment: JSON.stringify(file)
});
}) (data.post.file),
// data.post.attachments
...data.post.attachments.map(async (attachment, i) => {
const prefix = utils.fillNumber(i+1, data.post.attachments.length.toString().length);
const name = `attachments/${prefix}-${getFileName(attachment)}`;
const url = 'https://n1.kemono.su/data' + attachment.path;
const blob = await fetchBlob(url);
zip.file(name, blob, {
date: new Date(data.post.edited),
comment: JSON.stringify(attachment)
});
}),
];
await Promise.all(tasks);
return zip;
function getFileName(file) {
return file.name || file.path.slice(file.path.lastIndexOf('/')+1);
}
}
/**
* 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, comment=null) {
const options = { type: 'blob' };
comment !== null && (options.comment = comment);
const blob = await zip.generateAsync(options);
const url = URL.createObjectURL(blob);
const a = $$CrE({
tagName: 'a',
attrs: {
download: filename,
href: url
}
});
a.click();
}
/**
* Fetch blob data from given url \
* Queued function of _fetchBlob
* @param {string} url
* @returns {Promise<Blob>}
*/
function fetchBlob(...args) {
return queueTask(() => _fetchBlob(...args), 'fetchBlob');
}
/**
* Fetch blob data from given url
* @param {string} url
* @returns {Promise<Blob>}
*/
function _fetchBlob(url) {
return new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET', url,
responseType: 'blob',
onload(e) {
e.status === 200 ? resolve(e.response) : reject(e)
},
onerror: err => reject(err)
});
});
}
}
}, {
id: 'user-interface',
dependencies: ['utils', 'api', 'downloader'],
async func() {
const utils = require('utils');
const api = require('api');
const downloader = require('downloader');
let downloading = false;
// Console User Interface
const ConsoleUI = utils.window.ZIP = {
require,
api,
downloader,
};
// Menu User Interface
GM_registerMenuCommand(CONST.Text.DownloadZip, downloadCurrentPost);
// Graphical User Interface
// Make button
const dlbtn = $$CrE({
tagName: 'button',
styles: {
'background-color': 'transparent',
'color': 'white',
'border': 'transparent'
},
listeners: [['click', downloadCurrentPost]]
});
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',
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,
downloadCurrentPost,
getCurrentPost
}
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_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) {
dlprogress.innerText = `(${finished_steps}/${total_steps})`;
}
}
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: match[2],
post_id: match[3]
}
}
}
},
}]);
}) ();