Kemono zip download

Download kemono post in a zip file

ของเมื่อวันที่ 10-02-2025 ดู เวอร์ชันล่าสุด

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey, Greasemonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

คุณจะต้องติดตั้งส่วนขยาย เช่น Tampermonkey หรือ Violentmonkey เพื่อติดตั้งสคริปต์นี้

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