Kemono zip download

Download kemono post in a zip file

As of 12.02.2025. See апошняя версія.

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

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

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

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

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

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

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

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

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

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

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

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

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

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

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