Kemono zip download

Download kemono post in a zip file

Versión del día 10/2/2025. Echa un vistazo a la versión más reciente.

Tendrás que instalar una extensión para tu navegador como Tampermonkey, Greasemonkey o Violentmonkey si quieres utilizar este script.

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

Necesitarás instalar una extensión como Tampermonkey o Violentmonkey para instalar este script.

Necesitarás instalar una extensión como Tampermonkey o Userscripts para instalar este script.

Necesitará instalar una extensión como Tampermonkey para instalar este script.

Necesitarás instalar una extensión para administrar scripts de usuario si quieres instalar este script.

(Ya tengo un administrador de scripts de usuario, déjame instalarlo)

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión como Stylus para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

Necesitará instalar una extensión del gestor de estilos de usuario para instalar este estilo.

(Ya tengo un administrador de estilos de usuario, déjame instalarlo)

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

            return {
                window: win,
                fillNumber
            }
        }
    }, {
        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
             */
            async function downloadPost(service, creator_id, post_id) {
                const data = await API.Posts.post(service, creator_id, post_id);
                const zip = await zipPost(data);
                const filename = `${data.post.title}.zip`;
                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 });

                await Promise.all([
                    // 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)
                        });
                    }),
                ]);
                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: 'main',
        checkers: {
            type: 'regpath',
            value: /^\/([a-zA-Z]+)\/user\/(\d+)\/post\/(\d+)/
        },
        dependencies: ['utils', 'api', 'downloader'],
        async func() {
            const utils = require('utils');
            const api = require('api');
            const downloader = require('downloader');

            // User Interface
            const ConsoleInterface = utils.window.ZIP = {
                require,
                api,
                downloader,
            };
            
            let downloading = false;
            GM_registerMenuCommand(CONST.Text.DownloadZip, downloadCurrentPost);

            const action_panel = await detectDom('.post__actions');
            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
                }
            });
            dlbtn.append(dltext);
            action_panel.append(dlbtn);
            detectDom({
                root: action_panel,
                selector: 'button',
                callback: btn => btn !== dlbtn && action_panel.append(dlbtn)
            });

            return {
                ConsoleInterface,
                dlbtn, dltext,
                downloadCurrentPost,
            }

            async function downloadCurrentPost() {
                if (downloading) { return; }
                try {
                    downloading = true;
                    dltext.innerText = CONST.Text.Downloading;
                    const match = location.pathname.match(/^\/([a-zA-Z]+)\/user\/(\d+)\/post\/(\d+)/);
                    await downloader.downloadPost(match[1], match[2], match[3]);
                } finally {
                    downloading = false;
                    dltext.innerText = CONST.Text.DownloadZip;
                }
            }
        },
    }]);
}) ();