您需要先安装一个扩展,例如 篡改猴、Greasemonkey 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 暴力猴,之后才能安装此脚本。
您需要先安装一个扩展,例如 篡改猴 或 Userscripts ,之后才能安装此脚本。
您需要先安装一款用户脚本管理器扩展,例如 Tampermonkey,才能安装此脚本。
您需要先安装用户脚本管理器扩展后才能安装此脚本。
下载Kemono的内容为ZIP压缩文档
当前为
// ==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] } } } }, }]); }) ();