Kemono zip download

Download kemono post in a zip file

  1. // ==UserScript==
  2. // @name Kemono zip download
  3. // @name:zh-CN Kemono 下载为ZIP文件
  4. // @namespace https://greasyfork.org/users/667968-pyudng
  5. // @version 0.10
  6. // @description Download kemono post in a zip file
  7. // @description:zh-CN 下载Kemono的内容为ZIP压缩文档
  8. // @author PY-DNG
  9. // @license MIT
  10. // @match http*://*.kemono.su/*
  11. // @match http*://*.kemono.party/*
  12. // @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
  13. // @require https://update.greasyfork.org/scripts/456034/1546794/Basic%20Functions%20%28For%20userscripts%29.js
  14. // @require https://cdnjs.cloudflare.com/ajax/libs/jszip/3.10.1/jszip.min.js
  15. // @require https://update.greasyfork.org/scripts/458132/1138364/ItemSelector.js
  16. // @resource vue-js https://unpkg.com/vue@3.5.13/dist/vue.global.prod.js
  17. // @resource quasar-icon https://fonts.googleapis.com/css?family=Roboto:100,300,400,500,700,900|Material+Icons
  18. // @resource quasar-css https://cdn.jsdelivr.net/npm/quasar@2.15.1/dist/quasar.prod.css
  19. // @resource quasar-js https://cdn.jsdelivr.net/npm/quasar@2.15.1/dist/quasar.umd.prod.js
  20. // @connect kemono.su
  21. // @connect kemono.party
  22. // @icon https://kemono.su/favicon.ico
  23. // @grant GM_xmlhttpRequest
  24. // @grant GM_registerMenuCommand
  25. // @grant GM_addElement
  26. // @grant GM_getResourceText
  27. // @grant GM_setValue
  28. // @grant GM_getValue
  29. // @grant GM_addValueChangeListener
  30. // @run-at document-start
  31. // ==/UserScript==
  32.  
  33. /* eslint-disable no-multi-spaces */
  34. /* eslint-disable no-return-assign */
  35.  
  36. /* global LogLevel DoLog Err Assert $ $All $CrE $AEL $$CrE addStyle detectDom destroyEvent copyProp copyProps parseArgs escJsStr replaceText getUrlArgv dl_browser dl_GM AsyncManager queueTask FunctionLoader loadFuncs require isLoaded */
  37. /* global setImmediate JSZip ItemSelector Vue Quasar */
  38.  
  39. (function __MAIN__() {
  40. 'use strict';
  41.  
  42. const CONST = {
  43. TextAllLang: {
  44. DEFAULT: 'en-US',
  45. 'zh-CN': {
  46. DownloadZip: '下载为ZIP文件',
  47. DownloadPost: '下载当前作品为ZIP文件',
  48. DownloadCreator: '下载当前创作者为ZIP文件',
  49. Downloading: '正在下载...',
  50. SelectAll: '全选',
  51. TotalProgress: '总进度',
  52. ProgressBoardTitle: '下载进度',
  53. Settings: '设置',
  54. SaveContent: '保存文字内容到html文件',
  55. SaveApijson: '保存api结果到json文件',
  56. NoPrefix: '保存文件时不添加文件名前缀',
  57. FlattenFiles: '保存主文件和附件到同一级文件夹',
  58. FlattenFilesCaption: '主文件一般是封面图',
  59. },
  60. 'en-US': {
  61. DownloadZip: 'Download as ZIP file',
  62. DownloadPost: 'Download post as ZIP file',
  63. DownloadCreator: 'Download creator as ZIP file',
  64. Downloading: 'Downloading...',
  65. SelectAll: 'Select All',
  66. TotalProgress: 'Total Progress',
  67. ProgressBoardTitle: 'Download Progress',
  68. Settings: 'Settings',
  69. SaveContent: 'Save text content',
  70. SaveApijson: 'Save api result',
  71. NoPrefix: 'Do not add filename prefix',
  72. FlattenFiles: 'Save main file and attachments to same folder',
  73. FlattenFilesCaption: '"Main file" is usually the cover image',
  74. }
  75. }
  76. };
  77.  
  78. // Init language
  79. const i18n = Object.keys(CONST.TextAllLang).includes(navigator.language) ? navigator.language : CONST.TextAllLang.DEFAULT;
  80. CONST.Text = CONST.TextAllLang[i18n];
  81.  
  82. loadFuncs([{
  83. id: 'utils',
  84. async func() {
  85. const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
  86.  
  87. function fillNumber(number, length) {
  88. const str = number.toString();
  89. return '0'.repeat(length - str.length) + str;
  90. }
  91.  
  92. /**
  93. * Async task progress manager \
  94. * when awaiting async tasks, replace `await task` with `await manager.progress(task)` \
  95. * suppoerts sub-tasks, just `manager.sub(sub_steps, sub_callback)`
  96. */
  97. class ProgressManager {
  98. /** @type {*} */
  99. info;
  100. /** @type {number} */
  101. steps;
  102. /** @type {progressCallback} */
  103. callback;
  104. /** @type {number} */
  105. finished;
  106. /** @type {ProgressManager[]} */
  107. #children;
  108. /** @type {ProgressManager} */
  109. #parent;
  110.  
  111. /**
  112. * This callback is called each time a promise resolves
  113. * @callback progressCallback
  114. * @param {number} resolved_count
  115. * @param {number} total_count
  116. * @param {ProgressManager} manager
  117. */
  118.  
  119. /**
  120. * @param {number} [steps=0] - total steps count of the task
  121. * @param {progressCallback} [callback] - callback each time progress updates
  122. * @param {*} [info] - attach any data about this manager if need
  123. */
  124. constructor(steps=0, callback=function() {}, info=undefined) {
  125. this.steps = steps;
  126. this.callback = callback;
  127. this.info = info;
  128. this.finished = 0;
  129.  
  130. this.#children = [];
  131. this.#callCallback();
  132. }
  133.  
  134. add() { this.steps++; }
  135.  
  136. /**
  137. * @template {Promise | null} task
  138. * @param {task} [promise] - task to await, null is acceptable if no task to await
  139. * @param {number} [finished] - set this.finished to this value, defaults to this.finished+1
  140. * @returns {Awaited<task>}
  141. */
  142. async progress(promise, finished) {
  143. const val = await Promise.resolve(promise);
  144. try {
  145. this.finished = typeof finished === 'number' ? finished : this.finished + 1;
  146. this.#callCallback();
  147. //this.finished === this.steps && this.#parent && this.#parent.progress();
  148. } finally {
  149. return val;
  150. }
  151. }
  152.  
  153. /**
  154. * Creates a new ProgressManager as a sub-progress of this
  155. * @param {number} [steps=0] - total steps count of the task
  156. * @param {progressCallback} [callback] - callback each time progress updates, defaulting to parent.callback
  157. * @param {*} [info] - attach any data about the sub-manager if need
  158. */
  159. sub(steps, callback, info) {
  160. const manager = new ProgressManager(steps ?? 0, callback ?? this.callback, info);
  161. manager.#parent = this;
  162. this.#children.push(manager);
  163. return manager;
  164. }
  165.  
  166. #callCallback() {
  167. this.callback(this.finished, this.steps, this);
  168. }
  169.  
  170. get children() {
  171. return [...this.#children];
  172. }
  173.  
  174. get parent() {
  175. return this.#parent;
  176. }
  177.  
  178. /**
  179. * Resolves after all promise resolved, and callback each time one of them resolves
  180. * @param {Array<Promise>} promises
  181. * @param {progressCallback} callback
  182. */
  183. static async all(promises, callback) {
  184. const manager = new ProgressManager(promises.length, callback);
  185. await Promise.all(promises.map(promise => manager.progress(promise, callback)));
  186. }
  187. }
  188.  
  189. const PerformanceManager = (function() {
  190. class RunRecord {
  191. static #id = 0;
  192.  
  193. /** @typedef {'initialized' | 'running' | 'finished'} run_status */
  194. /** @type {number} */
  195. id;
  196. /** @type {number} */
  197. start;
  198. /** @type {number} */
  199. end;
  200. /** @type {number} */
  201. duration;
  202. /** @type {run_status} */
  203. status;
  204. /**
  205. * Anything for programmers to mark and read, uses as a description for this run
  206. * @type {*}
  207. */
  208. info;
  209.  
  210. /**
  211. * @param {*} [info] - Anything for programmers to mark and read, uses as a description for this run
  212. */
  213. constructor(info) {
  214. this.id = RunRecord.#id++;
  215. this.status = 'initialized';
  216. this.info = info;
  217. }
  218.  
  219. run() {
  220. const time = performance.now();
  221. this.start = time;
  222. this.status = 'running';
  223. return this;
  224. }
  225.  
  226. stop() {
  227. const time = performance.now();
  228. this.end = time;
  229. this.duration = this.end - this.start;
  230. this.status = 'finished';
  231. return this;
  232. }
  233. }
  234. class Task {
  235. /** @typedef {number | string | symbol} task_id */
  236. /** @type {task_id} */
  237. id;
  238. /** @type {RunRecord[]} */
  239. runs;
  240.  
  241. /**
  242. * @param {task_id} id
  243. */
  244. constructor(id) {
  245. this.id = id;
  246. this.runs = [];
  247. }
  248.  
  249. run(info) {
  250. const record = new RunRecord(info);
  251. record.run();
  252. this.runs.push(record);
  253. return record;
  254. }
  255.  
  256. get time() {
  257. return this.runs.reduce((time, record) => {
  258. if (record.status === 'finished') {
  259. time += record.duration;
  260. }
  261. return time;
  262. }, 0)
  263. }
  264. }
  265. class PerformanceManager {
  266. /** @type {Task[]} */
  267. tasks;
  268.  
  269. constructor() {
  270. this.tasks = [];
  271. }
  272.  
  273. /**
  274. * @param {task_id} id
  275. * @returns {Task | null}
  276. */
  277. getTask(id) {
  278. return this.tasks.find(task => task.id === id);
  279. }
  280.  
  281. /**
  282. * Creates a new task
  283. * @param {task_id} id
  284. * @returns {Task}
  285. */
  286. newTask(id) {
  287. Assert(!this.getTask(id), `given task id ${escJsStr(id)} is already in use`, TypeError);
  288.  
  289. const task = new Task(id);
  290. this.tasks.push(task);
  291. return task;
  292. }
  293.  
  294. /**
  295. * Runs a task
  296. * @param {task_id} id
  297. * @param {*} run_info - Anything for programmers to mark and read, uses as a description for this run
  298. * @returns {RunRecord}
  299. */
  300. run(task_id, run_info) {
  301. const task = this.getTask(task_id);
  302. Assert(task, `task of id ${escJsStr(task_id)} not found`, TypeError);
  303.  
  304. return task.run(run_info);
  305. }
  306.  
  307. totalTime(id) {
  308. if (id) {
  309. return this.getTask(id).time;
  310. } else {
  311. return this.tasks.reduce((timetable, task) => {
  312. timetable[task.id] = task.time;
  313. return timetable;
  314. }, {});
  315. }
  316. }
  317.  
  318. meanTime(id) {
  319. if (id) {
  320. const task = this.getTask(id);
  321. return task.time / task.runs.length;
  322. } else {
  323. return this.tasks.reduce((timetable, task) => {
  324. timetable[task.id] = task.time / task.runs.length;
  325. return timetable;
  326. }, {});
  327. }
  328. }
  329. }
  330.  
  331. return PerformanceManager;
  332. }) ();
  333.  
  334. return {
  335. window: win,
  336. fillNumber, ProgressManager, PerformanceManager
  337. }
  338. }
  339. }, {
  340. id: 'dependencies',
  341. desc: 'load dependencies like vue into the page',
  342. detectDom: ['head', 'body'],
  343. async func() {
  344. const deps = [{
  345. name: 'vue-js',
  346. type: 'script',
  347. }, {
  348. name: 'quasar-icon',
  349. type: 'style'
  350. }, {
  351. name: 'quasar-css',
  352. type: 'style'
  353. }, {
  354. name: 'quasar-js',
  355. type: 'script'
  356. }];
  357.  
  358. await Promise.all(deps.map(dep => {
  359. return new Promise((resolve, reject) => {
  360. switch (dep.type) {
  361. case 'script': {
  362. // Once load, dispatch load event on messager
  363. const evt_name = `load:${dep.name};${Date.now()}`;
  364. const rand = Math.random().toString();
  365. const messager = new EventTarget();
  366. const load_code = [
  367. '\n;',
  368. `window[${escJsStr(rand)}].dispatchEvent(new Event(${escJsStr(evt_name)}));`,
  369. `delete window[${escJsStr(rand)}];\n`
  370. ].join('\n');
  371. unsafeWindow[rand] = messager;
  372. $AEL(messager, evt_name, resolve);
  373. GM_addElement(document.head, 'script', {
  374. textContent: GM_getResourceText(dep.name) + load_code,
  375. });
  376. break;
  377. }
  378. case 'style': {
  379. GM_addElement(document.head, 'style', {
  380. textContent: GM_getResourceText(dep.name),
  381. });
  382. resolve();
  383. break;
  384. }
  385. }
  386. });
  387. }));
  388. }
  389. }, {
  390. id: 'api',
  391. desc: 'api for kemono',
  392. async func() {
  393. let api_key = null;
  394.  
  395. const Posts = {
  396. /**
  397. * Get a list of creator posts
  398. * @param {string} service - The service where the post is located
  399. * @param {number | string} creator_id - The ID of the creator
  400. * @param {string} [q] - Search query
  401. * @param {number} [o] - Result offset, stepping of 50 is enforced
  402. */
  403. posts(service, creator_id, q, o) {
  404. const search = {};
  405. q && (search.q = q);
  406. o && (search.o = o);
  407. return callApi({
  408. endpoint: `/${service}/user/${creator_id}`,
  409. search
  410. });
  411. },
  412.  
  413. /**
  414. * Get a specific post
  415. * @param {string} service
  416. * @param {number | string} creator_id
  417. * @param {number | string} post_id
  418. * @returns {Promise<Object>}
  419. */
  420. post(service, creator_id, post_id) {
  421. return callApi({
  422. endpoint: `/${service}/user/${creator_id}/post/${post_id}`
  423. });
  424. }
  425. };
  426. const Creators = {
  427. /**
  428. * Get a creator
  429. * @param {string} service - The service where the creator is located
  430. * @param {number | string} creator_id - The ID of the creator
  431. * @returns
  432. */
  433. profile(service, creator_id) {
  434. return callApi({
  435. endpoint: `/${service}/user/${creator_id}/profile`
  436. });
  437. }
  438. };
  439. const Custom = {
  440. /**
  441. * Get a list of creator's ALL posts, calling Post.posts for multiple times and joins the results
  442. * @param {string} service - The service where the post is located
  443. * @param {number | string} creator_id - The ID of the creator
  444. * @param {string} [q] - Search query
  445. */
  446. async all_posts(service, creator_id, q) {
  447. const posts = [];
  448. let offset = 0;
  449. let api_result = null;
  450. while (!api_result || api_result.length === 50) {
  451. api_result = await Posts.posts(service, creator_id, q, offset);
  452. posts.push(...api_result);
  453. offset += 50;
  454. }
  455. return posts;
  456. }
  457. };
  458. const API = {
  459. get key() { return api_key; },
  460. set key(val) { api_key = val; },
  461. Posts, Creators,
  462. Custom,
  463. callApi
  464. };
  465. return API;
  466.  
  467. /**
  468. * callApi detail object
  469. * @typedef {Object} api_detail
  470. * @property {string} endpoint - api endpoint
  471. * @property {Object} [search] - search params
  472. * @property {string} [method='GET']
  473. * @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
  474. */
  475.  
  476. /**
  477. * Do basic kemono api request
  478. * This is the queued version of _callApi
  479. * @param {api_detail} detail
  480. * @returns
  481. */
  482. function callApi(...args) {
  483. return queueTask(() => _callApi(...args), 'callApi');
  484. }
  485.  
  486. /**
  487. * Do basic kemono api request
  488. * @param {api_detail} detail
  489. * @returns
  490. */
  491. function _callApi(detail) {
  492. const search_string = new URLSearchParams(detail.search).toString();
  493. const url = `https://kemono.su/api/v1/${detail.endpoint.replace(/^\//, '')}` + (search_string ? '?' + search_string : '');
  494. const method = detail.method ?? 'GET';
  495. const auth = detail.auth ?? false;
  496.  
  497. return new Promise((resolve, reject) => {
  498. auth && api_key === null && reject('api key not found');
  499.  
  500. const options = {
  501. method, url,
  502. headers: {
  503. accept: 'application/json'
  504. },
  505. onload(e) {
  506. try {
  507. e.status === 200 ? resolve(JSON.parse(e.responseText)) : reject(e.responseText);
  508. } catch(err) {
  509. reject(err);
  510. }
  511. },
  512. onerror: err => reject(err)
  513. }
  514. if (typeof auth === 'string') {
  515. options.headers.Cookie = auth;
  516. } else if (auth === true) {
  517. options.headers.Cookie = api_key;
  518. }
  519. GM_xmlhttpRequest(options);
  520. });
  521. }
  522. }
  523. }, {
  524. id: 'gui',
  525. desc: 'reusable GUI components',
  526. dependencies: 'dependencies',
  527. async func() {
  528. class ProgressBoard {
  529. /** @type {Vue} */
  530. app;
  531.  
  532. /** Vue component instance */
  533. instance;
  534.  
  535. /** @typedef {{ finished: number, total: number }} progress */
  536. /** @typedef {{ name: string, progress: progress }} item */
  537. /** @type {item[]} */
  538. items;
  539.  
  540. constructor() {
  541. const that = this;
  542. this.items = [];
  543. // GUI
  544. const container = $$CrE({ tagName: 'div', styles: { position: 'fixed', zIndex: '-1' } });
  545. const board = $$CrE({
  546. tagName: 'div',
  547. classes: 'board'
  548. });
  549. board.innerHTML = `
  550. <q-layout view="hhh lpr fff">
  551. <q-dialog v-model="show" :class="{ mobile: $q.platform.is.mobile }" :position="$q.platform.is.mobile ? 'standard' : 'bottom'" seamless :full-width="$q.platform.is.mobile" :full-height="$q.platform.is.mobile">
  552. <q-card class="container-card">
  553. <q-card-section class="header row">
  554. <div class="text-h6">${ CONST.Text.ProgressBoardTitle }</div>
  555. <q-space></q-space>
  556. <q-btn :icon="minimized ? 'expand_less' : 'expand_more'" flat round dense @click="minimized = !minimized"></q-btn>
  557. <q-btn icon="close" flat round dense v-close-popup></q-btn>
  558. </q-card-section>
  559. <q-slide-transition>
  560. <q-card-section class="body" v-show="!minimized">
  561. <q-list class="list" :class="{ minimized: minimized }">
  562. <q-item tag="div" v-for="item of items">
  563. <q-item-section>
  564. <q-item-label>{{ item.name }}</q-item-label>
  565. </q-item-section>
  566. <q-item-section>
  567. <q-linear-progress animation-speed="500" :indeterminate="item.progress.total === 0" :value="item.progress.total > 0 ? item.progress.finished / item.progress.total : 0" :color="item.progress.total > 0 && item.progress.total === item.progress.finished ? 'green' : 'blue'"></q-linear-progress>
  568. </q-item-section>
  569. </q-item>
  570. </q-list>
  571. </q-card-section>
  572. </q-slide-transition>
  573. </q-card>
  574. </q-dialog>
  575. </q-layout>
  576. `;
  577. container.append(board);
  578. detectDom('html').then(html => html.append(container));
  579.  
  580. detectDom('head').then(head => addStyle(`
  581. :is(.container-card):not(.mobile *) {
  582. max-width: 45vw;
  583. }
  584. :is(.header, .body):not(.mobile *) {
  585. width: 40vw;
  586. }
  587. :is(.body .list):not(.mobile *) {
  588. height: 40vh;
  589. overflow-y: auto;
  590. }
  591. :is(.body .list.minimized):not(.mobile *) {
  592. overflow-y: hidden;
  593. }
  594. `, 'progress-board-style'));
  595.  
  596. this.app = Vue.createApp({
  597. data() {
  598. return {
  599. items: that.items,
  600. minimized: false,
  601. show: false
  602. }
  603. },
  604. methods: {
  605. debug() {
  606. debugger;
  607. }
  608. },
  609. mounted() {
  610. that.instance = this;
  611. }
  612. });
  613. this.app.use(Quasar);
  614. this.app.mount(board);
  615. Quasar.Dark.set(true);
  616. }
  617.  
  618. /**
  619. * update item's progress display on progress_board
  620. * @param {string} name - must be unique among all items
  621. * @param {progress} progress
  622. */
  623. update(name, progress) {
  624. let item = this.instance.items.find(item => item.name === name);
  625. if (!item) {
  626. item = { name, progress };
  627. this.instance.items.push(item);
  628. }
  629. item.progress = progress;
  630. }
  631.  
  632. /**
  633. * remove an item
  634. * @param {string} name
  635. */
  636. remove(name) {
  637. let item_index = this.instance.items.findIndex(item => item.name === name);
  638. if (item_index === -1) { return null; }
  639. return this.instance.items.splice(item_index, 1)[0];
  640. }
  641.  
  642. /**
  643. * remove all existing items
  644. */
  645. clear() {
  646. this.instance.items.splice(0, this.instance.items.length);
  647. }
  648.  
  649. get minimized() {
  650. return this.instance.minimized;
  651. }
  652.  
  653. set minimized(val) {
  654. this.instance.minimized = !!val;
  655. }
  656.  
  657. get closed() {
  658. return !this.instance.show;
  659. }
  660.  
  661. set closed(val) {
  662. this.instance.show = !val;
  663. }
  664. }
  665.  
  666. const board = new ProgressBoard();
  667.  
  668. return { ProgressBoard, board };
  669. }
  670. }, {
  671. id: 'downloader',
  672. desc: 'core zip download utils',
  673. dependencies: ['utils', 'api', 'settings'],
  674. async func() {
  675. const utils = require('utils');
  676. const API = require('api');
  677. const settings = require('settings');
  678.  
  679. // Performance record
  680. const perfmon = new utils.PerformanceManager();
  681. perfmon.newTask('fetchPost');
  682. perfmon.newTask('saveAs');
  683. perfmon.newTask('_fetchBlob');
  684.  
  685.  
  686. class DownloaderItem {
  687. /** @typedef {'file' | 'folder'} downloader_item_type */
  688. /**
  689. * Name of the item, CANNOT BE PATH
  690. * @type {string}
  691. */
  692. name;
  693. /** @type {downloader_item_type} */
  694. type;
  695.  
  696. /**
  697. * @param {string} name
  698. * @param {downloader_item_type} type
  699. */
  700. constructor(name, type) {
  701. this.name = name;
  702. this.type = type;
  703. }
  704. }
  705.  
  706. class DownloaderFile extends DownloaderItem{
  707. /** @type {Blob} */
  708. data;
  709. /** @type {Date} */
  710. date;
  711. /** @type {string} */
  712. comment;
  713.  
  714. /**
  715. * @param {string} name - name only, CANNOT BE PATH
  716. * @param {Blob} data
  717. * @param {Object} detail
  718. * @property {Date} [date]
  719. * @property {string} [comment]
  720. */
  721. constructor(name, data, detail) {
  722. super(name, 'file');
  723. this.data = data;
  724. Object.assign(this, detail);
  725. }
  726.  
  727. zip(jszip_instance) {
  728. const z = jszip_instance ?? new JSZip();
  729. const options = {};
  730. this.date && (options.date = this.date);
  731. this.comment && (options.comment = this.comment);
  732. z.file(this.name, this.data, options);
  733. return z;
  734. }
  735. }
  736.  
  737. class DownloaderFolder extends DownloaderItem {
  738. /** @type {Array<DownloaderFile | DownloaderFolder>} */
  739. children;
  740.  
  741. /**
  742. * @param {string} name - name only, CANNOT BE PATH
  743. * @param {Array<DownloaderFile | DownloaderFolder>} [children]
  744. */
  745. constructor(name, children) {
  746. super(name, 'folder');
  747. this.children = children && children.length ? children : [];
  748. }
  749.  
  750. zip(jszip_instance) {
  751. const z = jszip_instance ?? new JSZip();
  752. for (const child of this.children) {
  753. switch (child.type) {
  754. case 'file': {
  755. child.zip(z);
  756. break;
  757. }
  758. case 'folder': {
  759. const sub_z = z.folder(child.name);
  760. child.zip(sub_z);
  761. }
  762. }
  763. }
  764. return z;
  765. }
  766. }
  767.  
  768. /**
  769. * @typedef {Object} JSZip
  770. */
  771. /**
  772. * @typedef {Object} zipObject
  773. */
  774.  
  775. /**
  776. * Download one post in zip file
  777. * @param {string} service
  778. * @param {number | string} creator_id
  779. * @param {number | string} post_id
  780. * @param {function} [callback] - called each time made some progress, with two numeric arguments: finished_steps and total_steps
  781. */
  782. async function downloadPost(service, creator_id, post_id, callback = function() {}) {
  783. const manager = new utils.ProgressManager(3, callback, {
  784. layer: 'root', service, creator_id, post_id
  785. });
  786. const data = await manager.progress(API.Posts.post(service, creator_id, post_id));
  787. const folder = await manager.progress(fetchPost(data, manager));
  788. const zip = folder.zip();
  789. const filename = `${data.post.title}.zip`;
  790. manager.progress(await saveAs(filename, zip, manager));
  791. }
  792.  
  793. /**
  794. * @typedef {Object} PostInfo
  795. * @property {string} service,
  796. * @property {number} creator_id
  797. * @property {number} post_id
  798. */
  799.  
  800. /**
  801. * Download one or multiple posts in zip file, one folder for each post
  802. * @param {PostInfo | PostInfo[]} posts
  803. * @param {string} filename - file name of final zip file to be delivered to the user
  804. * @param {*} callback - Progress callback, like downloadPost's callback param
  805. */
  806. async function downloadPosts(posts, filename, callback = function() {}) {
  807. Array.isArray(posts) || (posts = [posts]);
  808. const manager = new utils.ProgressManager(posts.length + 1, callback, {
  809. layer: 'root', posts, filename
  810. });
  811.  
  812. // Fetch posts
  813. const post_folders = await Promise.all(
  814. posts.map(async post => {
  815. const data = await API.Posts.post(post.service, post.creator_id, post.post_id);
  816. const folder = await fetchPost(data, manager);
  817. await manager.progress();
  818. return folder;
  819. })
  820. );
  821.  
  822. // Merge all post's folders into one
  823. const folder = new DownloaderFolder(filename);
  824. post_folders.map(async post_folder => {
  825. folder.children.push(post_folder);
  826. })
  827.  
  828. // Convert folder to zip
  829. const zip = folder.zip();
  830.  
  831. // Deliver to user
  832. await manager.progress(saveAs(filename, zip, manager));
  833. }
  834.  
  835. /**
  836. * Fetch one post
  837. * @param {Object} api_data - kemono post api returned data object
  838. * @param {utils.ProgressManager} [manager]
  839. * @returns {Promise<DownloaderFolder>}
  840. */
  841. async function fetchPost(api_data, manager) {
  842. const perfmon_run = perfmon.run('fetchPost', api_data);
  843. const sub_manager = manager.sub(0, manager.callback, {
  844. layer: 'post', data: api_data
  845. });
  846.  
  847. const date = new Date(api_data.post.edited);
  848. const folder = new DownloaderFolder(escapePath(api_data.post.title), [
  849. ...settings.save_apijson ? [new DownloaderFile('data.json', JSON.stringify(api_data))] : [],
  850. ...settings.save_content ? [new DownloaderFile('content.html', api_data.post.content, { date })] : []
  851. ]);
  852.  
  853. let attachments_folder = folder;
  854. if (api_data.post.attachments.length && !settings.flatten_files) {
  855. attachments_folder = new DownloaderFolder('attachments');
  856. folder.children.push(attachments_folder);
  857. }
  858. const tasks = [
  859. // api_data.post.file
  860. (async function(file) {
  861. if (!file.path) { return; }
  862. sub_manager.add();
  863. const prefix = settings.no_prefix ? '' : 'file-';
  864. const ori_name = getFileName(file);
  865. const name = escapePath(prefix + ori_name);
  866. const url = getFileUrl(file);
  867. const blob = await sub_manager.progress(fetchBlob(url, sub_manager));
  868. folder.children.push(new DownloaderFile(
  869. name, blob, {
  870. date,
  871. comment: JSON.stringify(file)
  872. }
  873. ));
  874. }) (api_data.post.file),
  875.  
  876. // api_data.post.attachments
  877. ...api_data.post.attachments.map(async (attachment, i) => {
  878. if (!attachment.path) { return; }
  879. sub_manager.add();
  880. const prefix = settings.no_prefix ? '' : `${ utils.fillNumber(i+1, api_data.post.attachments.length.toString().length) }-`;
  881. const ori_name = getFileName(attachment);
  882. const name = escapePath(prefix + ori_name);
  883. const url = getFileUrl(attachment);
  884. const blob = await sub_manager.progress(fetchBlob(url, sub_manager));
  885. attachments_folder.children.push(new DownloaderFile(
  886. name, blob, {
  887. date,
  888. comment: JSON.stringify(attachment)
  889. }
  890. ));
  891. }),
  892. ];
  893. await Promise.all(tasks);
  894.  
  895. // Make sure sub_manager finishes even when no async tasks
  896. if (sub_manager.steps === 0) {
  897. sub_manager.steps = 1;
  898. await sub_manager.progress();
  899. }
  900.  
  901. perfmon_run.stop();
  902.  
  903. return folder;
  904.  
  905. function getFileName(file) {
  906. return file.name || file.path.slice(file.path.lastIndexOf('/')+1);
  907. }
  908.  
  909. function getFileUrl(file) {
  910. return (file.server ?? 'https://n1.kemono.su') + '/data' + file.path;
  911. }
  912. }
  913.  
  914. /**
  915. * Deliver zip file to user
  916. * @param {string} filename - filename (with extension, e.g. "file.zip")
  917. * @param {JSZip} zip,
  918. * @param {string | null} comment - zip file comment
  919. */
  920. async function saveAs(filename, zip, manager, comment=null) {
  921. const perfmon_run = perfmon.run('saveAs', filename);
  922. const sub_manager = manager.sub(100, manager.callback, {
  923. layer: 'zip', filename, zip
  924. });
  925.  
  926. const options = { type: 'blob' };
  927. comment !== null && (options.comment = comment);
  928. const blob = await zip.generateAsync(options, metadata => sub_manager.progress(null, metadata.percent));
  929. const url = URL.createObjectURL(blob);
  930. const a = $$CrE({
  931. tagName: 'a',
  932. attrs: {
  933. download: filename,
  934. href: url
  935. }
  936. });
  937. a.click();
  938.  
  939. perfmon_run.stop();
  940. }
  941.  
  942. /**
  943. * Fetch blob data from given url \
  944. * Queued function of _fetchBlob
  945. * @param {string} url
  946. * @param {utils.ProgressManager} [manager]
  947. * @returns {Promise<Blob>}
  948. */
  949. function fetchBlob(...args) {
  950. if (!fetchBlob.initialized) {
  951. queueTask.fetchBlob = {
  952. max: 3,
  953. sleep: 0
  954. };
  955. fetchBlob.initialized = true;
  956. }
  957.  
  958. return queueTask(() => _fetchBlob(...args), 'fetchBlob');
  959. }
  960.  
  961. /**
  962. * Fetch blob data from given url
  963. * @param {string} url
  964. * @param {utils.ProgressManager} [manager]
  965. * @param {number} [retry=3] - times to retry before throwing an error
  966. * @returns {Promise<Blob>}
  967. */
  968. async function _fetchBlob(url, manager, retry = 3) {
  969. const perfmon_run = perfmon.run('_fetchBlob', url);
  970. const sub_manager = manager.sub(0, manager.callback, {
  971. layer: 'file', url
  972. });
  973.  
  974. const blob = await new Promise((resolve, reject) => {
  975. GM_xmlhttpRequest({
  976. method: 'GET', url,
  977. responseType: 'blob',
  978. async onprogress(e) {
  979. sub_manager.steps = e.total;
  980. await sub_manager.progress(null, e.loaded);
  981. },
  982. onload(e) {
  983. e.status === 200 ? resolve(e.response) : onerror(e)
  984. },
  985. onerror
  986. });
  987.  
  988. async function onerror(err) {
  989. if (retry) {
  990. await sub_manager.progress(null, -1);
  991. const result = await _fetchBlob(url, manager, retry - 1);
  992. resolve(result);
  993. } else {
  994. reject(err)
  995. }
  996. }
  997. });
  998.  
  999. perfmon_run.stop();
  1000.  
  1001. return blob;
  1002. }
  1003.  
  1004. /**
  1005. * Replace unallowed special characters in a path part
  1006. * @param {string} path - a part of path, such as a folder name / file name
  1007. */
  1008. function escapePath(path) {
  1009. // Replace special characters
  1010. const chars_bank = {
  1011. '\\': '\',
  1012. '/': '/',
  1013. ':': ':',
  1014. '*': '*',
  1015. '?': '?',
  1016. '"': "'",
  1017. '<': '<',
  1018. '>': '>',
  1019. '|': '|'
  1020. };
  1021. for (const [char, replacement] of Object.entries(chars_bank)) {
  1022. path = path.replaceAll(char, replacement);
  1023. }
  1024.  
  1025. // Disallow ending with dots
  1026. path.endsWith('.') && (path += '_');
  1027. return path;
  1028. }
  1029.  
  1030. return {
  1031. downloadPost, downloadPosts,
  1032. fetchPost, saveAs,
  1033. perfmon
  1034. };
  1035. }
  1036. }, {
  1037. id: 'settings',
  1038. detectDom: 'html',
  1039. dependencies: 'dependencies',
  1040. params: ['GM_setValue', 'GM_getValue'],
  1041. async func(GM_setValue, GM_getValue) {
  1042. // settings 数据,所有设置项在这里配置
  1043. /**
  1044. * @typedef {Object} setting
  1045. * @property {string} title - 设置项名称
  1046. * @property {string | null} [caption] - 设置项副标题,可以省略;为假值时不渲染副标题元素
  1047. * @property {string} key - 用作settings 读/写对象的prop名称,也用作v-model值
  1048. * @property {string} storage - GM存储key
  1049. * @property {*} default - 用户未设置过时的默认值(初始值)
  1050. */
  1051. /** @type {setting[]} */
  1052. const settings_data = [{
  1053. title: CONST.Text.SaveContent,
  1054. caption: 'content.html',
  1055. key: 'save_content',
  1056. storage: 'save_content',
  1057. default: true,
  1058. }, {
  1059. title: CONST.Text.SaveApijson,
  1060. caption: 'data.json',
  1061. key: 'save_apijson',
  1062. storage: 'save_apijson',
  1063. default: true,
  1064. }, {
  1065. title: CONST.Text.NoPrefix,
  1066. key: 'no_prefix',
  1067. storage: 'no_prefix',
  1068. default: false,
  1069. }, {
  1070. title: CONST.Text.FlattenFiles,
  1071. caption: CONST.Text.FlattenFilesCaption,
  1072. key: 'flatten_files',
  1073. storage: 'flatten_files',
  1074. default: false
  1075. }];
  1076.  
  1077. // settings 读/写对象,既用于oFunc内读写,也直接作为oFunc返回值提供给其他功能函数
  1078. const settings = {};
  1079. settings_data.forEach(setting => {
  1080. Object.defineProperty(settings, setting.key, {
  1081. get() {
  1082. return GM_getValue(setting.storage, setting.default);
  1083. },
  1084. set(val) {
  1085. GM_setValue(setting.storage, !!val);
  1086. }
  1087. });
  1088. });
  1089.  
  1090. // 创建设置界面
  1091. const container = $$CrE({
  1092. tagName: 'div',
  1093. styles: { all: 'initial', position: 'fixed' }
  1094. })
  1095. const app_elm = $CrE('div');
  1096. app_elm.innerHTML = `
  1097. <q-layout view="hhh lpr fff">
  1098. <q-dialog v-model="show">
  1099. <q-card>
  1100. <q-card-section>
  1101. <div class="text-h6">${ CONST.Text.Settings }</div>
  1102. </q-card-section>
  1103. <q-card-section>
  1104. <q-list>
  1105. <q-item tag="label" v-for="setting of settings_data" v-ripple>
  1106. <q-item-section>
  1107. <q-item-label>{{ setting.title }}</q-item-label>
  1108. <q-item-label caption v-if="setting.caption">{{ setting.caption }}</q-item-label>
  1109. </q-item-section>
  1110. <q-item-section avatar>
  1111. <q-toggle color="orange" v-model="settings[setting.key]" @update:model-value="val => settings[setting.key] = val"></q-toggle>
  1112. </q-item-section>
  1113. </q-item>
  1114. </q-list>
  1115. </q-card-section>
  1116. </q-card>
  1117. </q-dialog>
  1118. </q-layout>
  1119. `;
  1120. container.append(app_elm);
  1121. $('html').append(container);
  1122. const app = Vue.createApp({
  1123. data() {
  1124. return {
  1125. show: false,
  1126. settings_data,
  1127. settings
  1128. };
  1129. },
  1130. mounted() {
  1131. GM_registerMenuCommand(CONST.Text.Settings, e => this.show = true);
  1132. GM_addValueChangeListener('settings', () => {
  1133. this.save_content = settings.save_content;
  1134. this.save_apijson = settings.save_apijson;
  1135. });
  1136. }
  1137. });
  1138. app.use(Quasar);
  1139. app.mount(app_elm);
  1140. Quasar.Dark.set(true);
  1141.  
  1142. return settings;
  1143. }
  1144. }, {
  1145. id: 'user-interface',
  1146. dependencies: ['utils', 'api', 'downloader', 'gui'],
  1147. async func() {
  1148. const utils = require('utils');
  1149. const api = require('api');
  1150. const downloader = require('downloader');
  1151. const gui = require('gui');
  1152.  
  1153. let downloading = false;
  1154. let selector = null;
  1155.  
  1156. // Console User Interface
  1157. const ConsoleUI = utils.window.ZIP = {
  1158. version: GM_info.script.version,
  1159. require,
  1160.  
  1161. api, downloader,
  1162. get ui() { return require('user-interface') },
  1163.  
  1164. downloadCurrentPost, downloadCurrentCreator, userDownload, getPageType
  1165. };
  1166.  
  1167. // Menu User Interface
  1168. GM_registerMenuCommand(CONST.Text.DownloadPost, downloadCurrentPost);
  1169. GM_registerMenuCommand(CONST.Text.DownloadCreator, downloadCurrentCreator);
  1170.  
  1171. // Graphical User Interface
  1172. // Make button
  1173. const dlbtn = $$CrE({
  1174. tagName: 'button',
  1175. styles: {
  1176. 'background-color': 'transparent',
  1177. 'color': 'white',
  1178. 'border': 'transparent'
  1179. },
  1180. listeners: [['click', userDownload]]
  1181. });
  1182. const dltext = $$CrE({
  1183. tagName: 'span',
  1184. props: {
  1185. innerText: CONST.Text.DownloadZip
  1186. }
  1187. });
  1188. const dlprogress = $$CrE({
  1189. tagName: 'span',
  1190. styles: {
  1191. display: 'none',
  1192. 'margin-left': '10px'
  1193. }
  1194. });
  1195. dlbtn.append(dltext);
  1196. dlbtn.append(dlprogress);
  1197.  
  1198. const board = gui.board;
  1199.  
  1200. // Place button each time a new action panel appears (meaning navigating into a post page)
  1201. let observer;
  1202. detectDom({
  1203. selector: '.post__actions, .user-header__actions',
  1204. callback: action_panel => {
  1205. // Hide dlprogress, its content is still for previous page
  1206. dlprogress.style.display = 'none';
  1207.  
  1208. // Append to action panel
  1209. action_panel.append(dlbtn);
  1210.  
  1211. // Disconnect old observer
  1212. observer?.disconnect();
  1213.  
  1214. // Observe action panel content change, always put download button in last place
  1215. observer = detectDom({
  1216. root: action_panel,
  1217. selector: 'button',
  1218. callback: btn => btn !== dlbtn && action_panel.append(dlbtn)
  1219. });
  1220. }
  1221. });
  1222.  
  1223. return {
  1224. ConsoleUI,
  1225. dlbtn, dltext,
  1226. get ['selector']() { return selector; },
  1227. downloadCurrentPost, downloadCurrentCreator,
  1228. getCurrentPost, getCurrentCreator, getPageType
  1229. }
  1230.  
  1231. function userDownload() {
  1232. const page_type = getPageType();
  1233. const func = ({
  1234. post: downloadCurrentPost,
  1235. creator: downloadCurrentCreator
  1236. })[page_type] ?? function() {};
  1237. return func();
  1238. }
  1239.  
  1240. async function downloadCurrentPost() {
  1241. const post_info = getCurrentPost();
  1242. if (downloading) { return; }
  1243. if (!post_info) { return; }
  1244.  
  1245. try {
  1246. downloading = true;
  1247. dlprogress.style.display = 'inline';
  1248. dltext.innerText = CONST.Text.Downloading;
  1249. board.minimized = false;
  1250. board.closed = false;
  1251. board.clear();
  1252. await downloader.downloadPost(
  1253. post_info.service,
  1254. post_info.creator_id,
  1255. post_info.post_id,
  1256. on_progress
  1257. );
  1258. } finally {
  1259. downloading = false;
  1260. dltext.innerText = CONST.Text.DownloadZip;
  1261. }
  1262.  
  1263. function on_progress(finished_steps, total_steps, manager) {
  1264. const info = manager.info;
  1265. info.layer === 'root' && (dlprogress.innerText = `(${finished_steps}/${total_steps})`);
  1266.  
  1267. const progress = { finished: finished_steps, total: total_steps };
  1268. info.layer === 'root' && board.update(CONST.Text.TotalProgress, progress);
  1269. info.layer === 'post' && board.update(info.data.post.title, progress);
  1270. info.layer === 'file' && board.update(info.url, progress);
  1271. info.layer === 'zip' && board.update(info.filename, progress);
  1272. board.closed = false;
  1273. }
  1274. }
  1275.  
  1276. async function downloadCurrentCreator() {
  1277. const creator_info = getCurrentCreator();
  1278. if (downloading) { return; }
  1279. if (!creator_info) { return; }
  1280.  
  1281. try {
  1282. downloading = true;
  1283. const profile = await api.Creators.profile(creator_info.service, creator_info.creator_id);
  1284. const posts = await api.Custom.all_posts(creator_info.service, creator_info.creator_id);
  1285. const selected_posts = await new Promise((resolve, reject) => {
  1286. if (!selector) {
  1287. selector = new ItemSelector();
  1288. selector.setTheme('dark');
  1289. selector.elements.container.style.setProperty('z-index', '1');
  1290. }
  1291. selector.show({
  1292. text: CONST.Text.SelectAll,
  1293. children: posts.map(post => ({
  1294. post,
  1295. text: post.title
  1296. }))
  1297. }, {
  1298. title: CONST.Text.DownloadCreator,
  1299. onok(e, json) {
  1300. const posts = json.children.map(obj => obj.post);
  1301. resolve(posts);
  1302. },
  1303. oncancel: e => resolve(null),
  1304. onclose: e => resolve(null)
  1305. });
  1306. });
  1307. if (selected_posts) {
  1308. dlprogress.style.display = 'inline';
  1309. dltext.innerText = CONST.Text.Downloading;
  1310. board.minimized = false;
  1311. board.closed = false;
  1312. board.clear();
  1313. await downloader.downloadPosts(selected_posts.map(post => ({
  1314. service: creator_info.service,
  1315. creator_id: creator_info.creator_id,
  1316. post_id: post.id
  1317. })), `${profile.name}.zip`, on_progress);
  1318. }
  1319. } finally {
  1320. downloading = false;
  1321. dltext.innerText = CONST.Text.DownloadZip;
  1322. }
  1323.  
  1324. function on_progress(finished_steps, total_steps, manager) {
  1325. const info = manager.info;
  1326. info.layer === 'root' && (dlprogress.innerText = `(${finished_steps}/${total_steps})`);
  1327.  
  1328. const progress = { finished: finished_steps, total: total_steps };
  1329. info.layer === 'root' && board.update(CONST.Text.TotalProgress, progress);
  1330. info.layer === 'post' && board.update(info.data.post.title, progress);
  1331. info.layer === 'file' && board.update(info.url, progress);
  1332. info.layer === 'zip' && board.update(info.filename, progress);
  1333. board.closed = false;
  1334. }
  1335. }
  1336.  
  1337. /**
  1338. * @typedef {Object} PostInfo
  1339. * @property {string} service,
  1340. * @property {number} creator_id
  1341. * @property {number} post_id
  1342. */
  1343. /**
  1344. * Get post info in current page
  1345. * @returns {PostInfo | null}
  1346. */
  1347. function getCurrentPost() {
  1348. const regpath = /^\/([a-zA-Z]+)\/user\/(\d+)\/post\/(\d+)/;
  1349. const match = location.pathname.match(regpath);
  1350. if (!match) {
  1351. return null;
  1352. } else {
  1353. return {
  1354. service: match[1],
  1355. creator_id: parseInt(match[2], 10),
  1356. post_id: parseInt(match[3], 10)
  1357. }
  1358. }
  1359. }
  1360.  
  1361. /**
  1362. * @typedef {Object} CreatorInfo
  1363. * @property {string} service
  1364. * @property {number} creator_id
  1365. */
  1366. /**
  1367. * Get creator info in current page
  1368. * @returns {CreatorInfo | null}
  1369. */
  1370. function getCurrentCreator() {
  1371. const regpath = /^\/([a-zA-Z]+)\/user\/(\d+)/;
  1372. const match = location.pathname.match(regpath);
  1373. if (!match) {
  1374. return null;
  1375. } else {
  1376. return {
  1377. service: match[1],
  1378. creator_id: parseInt(match[2], 10)
  1379. }
  1380. }
  1381. }
  1382.  
  1383. /** @typedef { 'post' | 'creator' } page_type */
  1384. /**
  1385. * @returns {page_type}
  1386. */
  1387. function getPageType() {
  1388. const matchers = {
  1389. post: {
  1390. type: 'regpath',
  1391. value: /^\/([a-zA-Z]+)\/user\/(\d+)\/post\/(\d+)/
  1392. },
  1393. creator: {
  1394. type: 'func',
  1395. value: () => /^\/([a-zA-Z]+)\/user\/(\d+)/.test(location.pathname) && !location.pathname.includes('/post/')
  1396. },
  1397. }
  1398. for (const [type, matcher] of Object.entries(matchers)) {
  1399. if (FunctionLoader.testCheckers(matcher)) {
  1400. return type;
  1401. }
  1402. }
  1403. }
  1404. },
  1405. }]);
  1406. }) ();