Kemono zip download

Download kemono post in a zip file

Version au 10/02/2025. Voir la dernière version.

Vous devrez installer une extension telle que Tampermonkey, Greasemonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Violentmonkey pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey ou Userscripts pour installer ce script.

Vous devrez installer une extension telle que Tampermonkey pour installer ce script.

Vous devrez installer une extension de gestionnaire de script utilisateur pour installer ce script.

(J'ai déjà un gestionnaire de scripts utilisateur, laissez-moi l'installer !)

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension telle que Stylus pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

Vous devrez installer une extension du gestionnaire de style pour utilisateur pour installer ce style.

(J'ai déjà un gestionnaire de style utilisateur, laissez-moi l'installer!)

// ==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;
                }
            }
        },
    }]);
}) ();