Kemono zip download

Download kemono post in a zip file

2025-02-24 일자. 최신 버전을 확인하세요.

질문, 리뷰하거나, 이 스크립트를 신고하세요.
  1. // ==UserScript==
  2. // @name Kemono zip download
  3. // @name:zh-CN Kemono 下载为ZIP文件
  4. // @namespace https://greasyfork.org/users/667968-pyudng
  5. // @version 0.5
  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/1532680/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. // @connect kemono.su
  17. // @connect kemono.party
  18. // @icon https://kemono.su/favicon.ico
  19. // @grant GM_xmlhttpRequest
  20. // @grant GM_registerMenuCommand
  21. // @run-at document-start
  22. // ==/UserScript==
  23.  
  24. /* eslint-disable no-multi-spaces */
  25. /* eslint-disable no-return-assign */
  26.  
  27. /* 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 */
  28. /* global setImmediate, JSZip, ItemSelector */
  29.  
  30. (function __MAIN__() {
  31. 'use strict';
  32.  
  33. const CONST = {
  34. TextAllLang: {
  35. DEFAULT: 'en-US',
  36. 'zh-CN': {
  37. DownloadZip: '下载为ZIP文件',
  38. DownloadPost: '下载当前作品为ZIP文件',
  39. DownloadCreator: '下载当前创作者为ZIP文件',
  40. Downloading: '正在下载...',
  41. SelectAll: '全选',
  42. TotalProgress: '总进度',
  43. ProgressBoardTitle: '下载进度'
  44. },
  45. 'en-US': {
  46. DownloadZip: 'Download as ZIP file',
  47. DownloadPost: 'Download post as ZIP file',
  48. DownloadCreator: 'Download creator as ZIP file',
  49. Downloading: 'Downloading...',
  50. SelectAll: 'Select All',
  51. TotalProgress: 'Total Progress',
  52. ProgressBoardTitle: 'Download Progress'
  53. }
  54. }
  55. };
  56.  
  57. // Init language
  58. const i18n = Object.keys(CONST.TextAllLang).includes(navigator.language) ? navigator.language : CONST.TextAllLang.DEFAULT;
  59. CONST.Text = CONST.TextAllLang[i18n];
  60.  
  61. loadFuncs([{
  62. id: 'utils',
  63. async func() {
  64. const win = typeof unsafeWindow !== 'undefined' ? unsafeWindow : window;
  65.  
  66. function fillNumber(number, length) {
  67. const str = number.toString();
  68. return '0'.repeat(length - str.length) + str;
  69. }
  70.  
  71. /**
  72. * Async task progress manager \
  73. * when awaiting async tasks, replace `await task` with `await manager.progress(task)` \
  74. * suppoerts sub-tasks, just `manager.sub(sub_steps, sub_callback)`
  75. */
  76. class ProgressManager {
  77. /** @type {*} */
  78. info;
  79. /** @type {number} */
  80. steps;
  81. /** @type {progressCallback} */
  82. callback;
  83. /** @type {number} */
  84. finished;
  85. /** @type {ProgressManager[]} */
  86. #children;
  87. /** @type {ProgressManager} */
  88. #parent;
  89.  
  90. /**
  91. * This callback is called each time a promise resolves
  92. * @callback progressCallback
  93. * @param {number} resolved_count
  94. * @param {number} total_count
  95. * @param {ProgressManager} manager
  96. */
  97.  
  98. /**
  99. * @param {number} [steps=0] - total steps count of the task
  100. * @param {progressCallback} [callback] - callback each time progress updates
  101. * @param {*} [info] - attach any data about this manager if need
  102. */
  103. constructor(steps=0, callback=function() {}, info=undefined) {
  104. this.steps = steps;
  105. this.callback = callback;
  106. this.info = info;
  107. this.finished = 0;
  108.  
  109. this.#children = [];
  110. this.#callCallback();
  111. }
  112.  
  113. add() { this.steps++; }
  114.  
  115. /**
  116. * @template {Promise | null} task
  117. * @param {task} [promise] - task to await, null is acceptable if no task to await
  118. * @param {number} [finished] - set this.finished to this value, defaults to this.finished+1
  119. * @returns {Awaited<task>}
  120. */
  121. async progress(promise, finished) {
  122. const val = await Promise.resolve(promise);
  123. try {
  124. this.finished = typeof finished === 'number' ? finished : this.finished + 1;
  125. this.#callCallback();
  126. //this.finished === this.steps && this.#parent && this.#parent.progress();
  127. } finally {
  128. return val;
  129. }
  130. }
  131.  
  132. /**
  133. * Creates a new ProgressManager as a sub-progress of this
  134. * @param {number} [steps=0] - total steps count of the task
  135. * @param {progressCallback} [callback] - callback each time progress updates, defaulting to parent.callback
  136. * @param {*} [info] - attach any data about the sub-manager if need
  137. */
  138. sub(steps, callback, info) {
  139. const manager = new ProgressManager(steps ?? 0, callback ?? this.callback, info);
  140. manager.#parent = this;
  141. this.#children.push(manager);
  142. return manager;
  143. }
  144.  
  145. #callCallback() {
  146. this.callback(this.finished, this.steps, this);
  147. }
  148.  
  149. get children() {
  150. return [...this.#children];
  151. }
  152.  
  153. get parent() {
  154. return this.#parent;
  155. }
  156.  
  157. /**
  158. * Resolves after all promise resolved, and callback each time one of them resolves
  159. * @param {Array<Promise>} promises
  160. * @param {progressCallback} callback
  161. */
  162. static async all(promises, callback) {
  163. const manager = new ProgressManager(promises.length, callback);
  164. await Promise.all(promises.map(promise => manager.progress(promise, callback)));
  165. }
  166. }
  167.  
  168. const PerformanceManager = (function() {
  169. class RunRecord {
  170. static #id = 0;
  171.  
  172. /** @typedef {'initialized' | 'running' | 'finished'} run_status */
  173. /** @type {number} */
  174. id;
  175. /** @type {number} */
  176. start;
  177. /** @type {number} */
  178. end;
  179. /** @type {number} */
  180. duration;
  181. /** @type {run_status} */
  182. status;
  183. /**
  184. * Anything for programmers to mark and read, uses as a description for this run
  185. * @type {*}
  186. */
  187. info;
  188.  
  189. /**
  190. * @param {*} [info] - Anything for programmers to mark and read, uses as a description for this run
  191. */
  192. constructor(info) {
  193. this.id = RunRecord.#id++;
  194. this.status = 'initialized';
  195. this.info = info;
  196. }
  197.  
  198. run() {
  199. const time = performance.now();
  200. this.start = time;
  201. this.status = 'running';
  202. return this;
  203. }
  204.  
  205. stop() {
  206. const time = performance.now();
  207. this.end = time;
  208. this.duration = this.end - this.start;
  209. this.status = 'finished';
  210. return this;
  211. }
  212. }
  213. class Task {
  214. /** @typedef {number | string | symbol} task_id */
  215. /** @type {task_id} */
  216. id;
  217. /** @type {RunRecord[]} */
  218. runs;
  219.  
  220. /**
  221. * @param {task_id} id
  222. */
  223. constructor(id) {
  224. this.id = id;
  225. this.runs = [];
  226. }
  227.  
  228. run(info) {
  229. const record = new RunRecord(info);
  230. record.run();
  231. this.runs.push(record);
  232. return record;
  233. }
  234.  
  235. get time() {
  236. return this.runs.reduce((time, record) => {
  237. if (record.status === 'finished') {
  238. time += record.duration;
  239. }
  240. return time;
  241. }, 0)
  242. }
  243. }
  244. class PerformanceManager {
  245. /** @type {Task[]} */
  246. tasks;
  247.  
  248. constructor() {
  249. this.tasks = [];
  250. }
  251.  
  252. /**
  253. * @param {task_id} id
  254. * @returns {Task | null}
  255. */
  256. getTask(id) {
  257. return this.tasks.find(task => task.id === id);
  258. }
  259.  
  260. /**
  261. * Creates a new task
  262. * @param {task_id} id
  263. * @returns {Task}
  264. */
  265. newTask(id) {
  266. Assert(!this.getTask(id), `given task id ${escJsStr(id)} is already in use`, TypeError);
  267.  
  268. const task = new Task(id);
  269. this.tasks.push(task);
  270. return task;
  271. }
  272.  
  273. /**
  274. * Runs a task
  275. * @param {task_id} id
  276. * @param {*} run_info - Anything for programmers to mark and read, uses as a description for this run
  277. * @returns {RunRecord}
  278. */
  279. run(task_id, run_info) {
  280. const task = this.getTask(task_id);
  281. Assert(task, `task of id ${escJsStr(task_id)} not found`, TypeError);
  282.  
  283. return task.run(run_info);
  284. }
  285.  
  286. totalTime(id) {
  287. if (id) {
  288. return this.getTask(id).time;
  289. } else {
  290. return this.tasks.reduce((timetable, task) => {
  291. timetable[task.id] = task.time;
  292. return timetable;
  293. }, {});
  294. }
  295. }
  296.  
  297. meanTime(id) {
  298. if (id) {
  299. const task = this.getTask(id);
  300. return task.time / task.runs.length;
  301. } else {
  302. return this.tasks.reduce((timetable, task) => {
  303. timetable[task.id] = task.time / task.runs.length;
  304. return timetable;
  305. }, {});
  306. }
  307. }
  308. }
  309.  
  310. return PerformanceManager;
  311. }) ();
  312.  
  313. return {
  314. window: win,
  315. fillNumber, ProgressManager, PerformanceManager
  316. }
  317. }
  318. }, {
  319. id: 'api',
  320. desc: 'api for kemono',
  321. async func() {
  322. let api_key = null;
  323.  
  324. const Posts = {
  325. /**
  326. * Get a list of creator posts
  327. * @param {string} service - The service where the post is located
  328. * @param {number | string} creator_id - The ID of the creator
  329. * @param {string} [q] - Search query
  330. * @param {number} [o] - Result offset, stepping of 50 is enforced
  331. */
  332. posts(service, creator_id, q, o) {
  333. const search = {};
  334. q && (search.q = q);
  335. o && (search.o = o);
  336. return callApi({
  337. endpoint: `/${service}/user/${creator_id}`,
  338. search
  339. });
  340. },
  341.  
  342. /**
  343. * Get a specific post
  344. * @param {string} service
  345. * @param {number | string} creator_id
  346. * @param {number | string} post_id
  347. * @returns {Promise<Object>}
  348. */
  349. post(service, creator_id, post_id) {
  350. return callApi({
  351. endpoint: `/${service}/user/${creator_id}/post/${post_id}`
  352. });
  353. }
  354. };
  355. const Creators = {
  356. /**
  357. * Get a creator
  358. * @param {string} service - The service where the creator is located
  359. * @param {number | string} creator_id - The ID of the creator
  360. * @returns
  361. */
  362. profile(service, creator_id) {
  363. return callApi({
  364. endpoint: `/${service}/user/${creator_id}/profile`
  365. });
  366. }
  367. };
  368. const Custom = {
  369. /**
  370. * Get a list of creator's ALL posts, calling Post.posts for multiple times and joins the results
  371. * @param {string} service - The service where the post is located
  372. * @param {number | string} creator_id - The ID of the creator
  373. * @param {string} [q] - Search query
  374. */
  375. async all_posts(service, creator_id, q) {
  376. const posts = [];
  377. let offset = 0;
  378. let api_result = null;
  379. while (!api_result || api_result.length === 50) {
  380. api_result = await Posts.posts(service, creator_id, q, offset);
  381. posts.push(...api_result);
  382. offset += 50;
  383. }
  384. return posts;
  385. }
  386. };
  387. const API = {
  388. get key() { return api_key; },
  389. set key(val) { api_key = val; },
  390. Posts, Creators,
  391. Custom,
  392. callApi
  393. };
  394. return API;
  395.  
  396. /**
  397. * callApi detail object
  398. * @typedef {Object} api_detail
  399. * @property {string} endpoint - api endpoint
  400. * @property {Object} [search] - search params
  401. * @property {string} [method='GET']
  402. * @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
  403. */
  404.  
  405. /**
  406. * Do basic kemono api request
  407. * This is the queued version of _callApi
  408. * @param {api_detail} detail
  409. * @returns
  410. */
  411. function callApi(...args) {
  412. return queueTask(() => _callApi(...args), 'callApi');
  413. }
  414.  
  415. /**
  416. * Do basic kemono api request
  417. * @param {api_detail} detail
  418. * @returns
  419. */
  420. function _callApi(detail) {
  421. const search_string = new URLSearchParams(detail.search).toString();
  422. const url = `https://kemono.su/api/v1/${detail.endpoint.replace(/^\//, '')}` + (search_string ? '?' + search_string : '');
  423. const method = detail.method ?? 'GET';
  424. const auth = detail.auth ?? false;
  425.  
  426. return new Promise((resolve, reject) => {
  427. auth && api_key === null && reject('api key not found');
  428.  
  429. const options = {
  430. method, url,
  431. headers: {
  432. accept: 'application/json'
  433. },
  434. onload(e) {
  435. try {
  436. e.status === 200 ? resolve(JSON.parse(e.responseText)) : reject(e.responseText);
  437. } catch(err) {
  438. reject(err);
  439. }
  440. },
  441. onerror: err => reject(err)
  442. }
  443. if (typeof auth === 'string') {
  444. options.headers.Cookie = auth;
  445. } else if (auth === true) {
  446. options.headers.Cookie = api_key;
  447. }
  448. GM_xmlhttpRequest(options);
  449. });
  450. }
  451. }
  452. }, {
  453. id: 'gui',
  454. desc: 'reusable GUI components',
  455. async func() {
  456. class ProgressBoard {
  457. /** @type {Object<string, HTMLElement>} */
  458. elements;
  459. items;
  460.  
  461. constructor() {
  462. const container = $CrE('div');
  463. const shadowroot = container.attachShadow({ mode: 'open' });
  464. const board = $$CrE({
  465. tagName: 'div',
  466. classes: 'board'
  467. });
  468. const header = $$CrE({
  469. tagName: 'div',
  470. classes: 'header'
  471. });
  472. const body = $$CrE({
  473. tagName: 'div',
  474. classes: 'body'
  475. });
  476. const title = $$CrE({
  477. tagName: 'span',
  478. classes: 'title',
  479. props: { innerText: CONST.Text.ProgressBoardTitle }
  480. });
  481. const btn_group = $$CrE({
  482. tagName: 'span',
  483. classes: 'header-buttons'
  484. });
  485. const close = $$CrE({
  486. tagName: 'span',
  487. classes: 'close',
  488. props: { innerText: 'x' },
  489. listeners: [['click', e => board.classList.toggle('show')]]
  490. });
  491. const minimize = $$CrE({
  492. tagName: 'span',
  493. classes: 'minimize',
  494. props: { innerText: '-' },
  495. listeners: [['click', e => body.classList.toggle('show')]]
  496. });
  497. const style = $$CrE({
  498. tagName: 'style',
  499. props: {
  500. innerText: `
  501. .board {
  502. position: fixed;
  503. right: 0;
  504. bottom: 0;
  505. z-index: 1;
  506. background-color: rgb(40, 42, 46);
  507. color: var(--colour0-primary);
  508. width: 40vw;
  509. display: none;
  510. }
  511.  
  512. .show:not(#important) {
  513. display: block;
  514. }
  515.  
  516. .header {
  517. height: 1.5em;
  518. }
  519.  
  520. .header-buttons {
  521. float: right;
  522. direction: rtl;
  523. }
  524.  
  525. .header-buttons>* {
  526. display: inline-block;
  527. text-align: center;
  528. width: 1.5em;
  529. font-size: 1.2em;
  530. font-weight: bolder;
  531. cursor: pointer;
  532. }
  533.  
  534. .body {
  535. overflow: auto;
  536. display: none;
  537. height: calc(40vh - 1.5em);
  538. }
  539.  
  540. .header, .body {
  541. padding: 10px;
  542. }
  543.  
  544. .line {
  545. display: flex;
  546. flex-direction: row;
  547. align-items: center;
  548. }
  549.  
  550. .text {
  551. width: calc(60% - 10px);
  552. display: inline-block;
  553. overflow: hidden;
  554. padding-right: 10px;
  555. }
  556.  
  557. .progress {
  558. width: 40%;
  559. }
  560. `
  561. }
  562. });
  563. btn_group.append(close, minimize);
  564. header.append(title, btn_group);
  565. board.append(header, body);
  566. shadowroot.append(board, style);
  567. detectDom('body').then(body => body.append(container));
  568.  
  569. this.elements = {
  570. container, shadowroot, board, header,
  571. body, close, minimize, style
  572. };
  573. this.items = {};
  574. }
  575.  
  576. /**
  577. * update item's progress display on progress_board
  578. * @param {string} name - must be unique among all items
  579. * @param {Object} progress
  580. * @param {number} progress.finished
  581. * @param {number} progress.total
  582. */
  583. update(name, progress) {
  584. const elements = this.elements;
  585. if (!this.items[name]) {
  586. const line = $$CrE({ tagName: 'p', classes: 'line' });
  587. const text = $$CrE({ tagName: 'span', classes: 'text', props: { innerText: name } });
  588. const progbar = $$CrE({tagName: 'progress', classes: 'progress' });
  589. line.append(text, progbar);
  590. elements.body.append(line);
  591. this.items[name] = { line, text, progbar };
  592. }
  593. elements.board.classList.add('show');
  594. this.items[name].progbar.max = progress.total;
  595. this.items[name].progbar.value = progress.finished;
  596. this.items[name].progbar.title = `${progress.finished / progress.total * 100}%`;
  597. }
  598.  
  599. /**
  600. * remove an item
  601. * @param {string} name
  602. */
  603. remove(name) {
  604. const item = this.items[name];
  605. if (!item) { return null; }
  606. item.line.remove();
  607. delete this.items[name];
  608. return item;
  609. }
  610.  
  611. /**
  612. * remove all existing items
  613. */
  614. clear() {
  615. for (const name of Object.keys(this.items)) {
  616. this.remove(name);
  617. }
  618. }
  619.  
  620. get minimized() {
  621. return !this.elements.body.classList.contains('show');
  622. }
  623.  
  624. set minimized(val) {
  625. this.elements.body.classList[val ? 'remove' : 'add']('show');
  626. }
  627.  
  628. get closed() {
  629. return !this.elements.container.classList.contains('show');
  630. }
  631.  
  632. set closed(val) {
  633. this.elements.container.classList[val ? 'remove' : 'add']('show');
  634. }
  635. }
  636.  
  637. const board = new ProgressBoard();
  638.  
  639. return { ProgressBoard, board };
  640. }
  641. }, {
  642. id: 'downloader',
  643. desc: 'core zip download utils',
  644. dependencies: ['utils', 'api'],
  645. async func() {
  646. const utils = require('utils');
  647. const API = require('api');
  648.  
  649. // Performance record
  650. const perfmon = new utils.PerformanceManager();
  651. perfmon.newTask('fetchPost');
  652. perfmon.newTask('saveAs');
  653. perfmon.newTask('_fetchBlob');
  654.  
  655.  
  656. class DownloaderItem {
  657. /** @typedef {'file' | 'folder'} downloader_item_type */
  658. /**
  659. * Name of the item, CANNOT BE PATH
  660. * @type {string}
  661. */
  662. name;
  663. /** @type {downloader_item_type} */
  664. type;
  665.  
  666. /**
  667. * @param {string} name
  668. * @param {downloader_item_type} type
  669. */
  670. constructor(name, type) {
  671. this.name = name;
  672. this.type = type;
  673. }
  674. }
  675.  
  676. class DownloaderFile extends DownloaderItem{
  677. /** @type {Blob} */
  678. data;
  679. /** @type {Date} */
  680. date;
  681. /** @type {string} */
  682. comment;
  683.  
  684. /**
  685. * @param {string} name - name only, CANNOT BE PATH
  686. * @param {Blob} data
  687. * @param {Object} detail
  688. * @property {Date} [date]
  689. * @property {string} [comment]
  690. */
  691. constructor(name, data, detail) {
  692. super(name, 'file');
  693. this.data = data;
  694. Object.assign(this, detail);
  695. }
  696.  
  697. zip(jszip_instance) {
  698. const z = jszip_instance ?? new JSZip();
  699. const options = {};
  700. this.date && (options.date = this.date);
  701. this.comment && (options.comment = this.comment);
  702. z.file(this.name, this.data, options);
  703. return z;
  704. }
  705. }
  706.  
  707. class DownloaderFolder extends DownloaderItem {
  708. /** @type {Array<DownloaderFile | DownloaderFolder>} */
  709. children;
  710.  
  711. /**
  712. * @param {string} name - name only, CANNOT BE PATH
  713. * @param {Array<DownloaderFile | DownloaderFolder>} [children]
  714. */
  715. constructor(name, children) {
  716. super(name, 'folder');
  717. this.children = children && children.length ? children : [];
  718. }
  719.  
  720. zip(jszip_instance) {
  721. const z = jszip_instance ?? new JSZip();
  722. for (const child of this.children) {
  723. switch (child.type) {
  724. case 'file': {
  725. child.zip(z);
  726. break;
  727. }
  728. case 'folder': {
  729. const sub_z = z.folder(child.name);
  730. child.zip(sub_z);
  731. }
  732. }
  733. }
  734. return z;
  735. }
  736. }
  737.  
  738. /**
  739. * @typedef {Object} JSZip
  740. */
  741. /**
  742. * @typedef {Object} zipObject
  743. */
  744.  
  745. /**
  746. * Download one post in zip file
  747. * @param {string} service
  748. * @param {number | string} creator_id
  749. * @param {number | string} post_id
  750. * @param {function} [callback] - called each time made some progress, with two numeric arguments: finished_steps and total_steps
  751. */
  752. async function downloadPost(service, creator_id, post_id, callback = function() {}) {
  753. const manager = new utils.ProgressManager(3, callback, {
  754. layer: 'root', service, creator_id, post_id
  755. });
  756. const data = await manager.progress(API.Posts.post(service, creator_id, post_id));
  757. const folder = await manager.progress(fetchPost(data, manager));
  758. const zip = folder.zip();
  759. const filename = `${data.post.title}.zip`;
  760. manager.progress(await saveAs(filename, zip, manager));
  761. }
  762.  
  763. /**
  764. * @typedef {Object} PostInfo
  765. * @property {string} service,
  766. * @property {number} creator_id
  767. * @property {number} post_id
  768. */
  769.  
  770. /**
  771. * Download one or multiple posts in zip file, one folder for each post
  772. * @param {PostInfo | PostInfo[]} posts
  773. * @param {*} callback - Progress callback, like downloadPost's callback param
  774. */
  775. async function downloadPosts(posts, filename, callback = function() {}) {
  776. Array.isArray(posts) || (posts = [posts]);
  777. const manager = new utils.ProgressManager(2, callback, {
  778. layer: 'root', posts, filename
  779. });
  780.  
  781. // Fetch posts
  782. const post_folders = await manager.progress(Promise.all(
  783. posts.map(async post => {
  784. const data = await API.Posts.post(post.service, post.creator_id, post.post_id);
  785. const folder = await fetchPost(data, manager);
  786. return folder;
  787. })
  788. ));
  789.  
  790. // Merge all post's folders into one
  791. const folder = new DownloaderFolder(filename);
  792. post_folders.map(async post_folder => {
  793. folder.children.push(post_folder);
  794. })
  795.  
  796. // Convert folder to zip
  797. const zip = folder.zip();
  798.  
  799. // Deliver to user
  800. await manager.progress(saveAs(filename, zip, manager));
  801. }
  802.  
  803. /**
  804. * Fetch one post
  805. * @param {Object} api_data - kemono post api returned data object
  806. * @param {utils.ProgressManager} [manager]
  807. * @returns {Promise<DownloaderFolder>}
  808. */
  809. async function fetchPost(api_data, manager) {
  810. const perfmon_run = perfmon.run('fetchPost', api_data);
  811. const sub_manager = manager.sub(0, manager.callback, {
  812. layer: 'post', data: api_data
  813. });
  814.  
  815. const date = new Date(api_data.post.edited);
  816. const folder = new DownloaderFolder(api_data.post.title, [
  817. new DownloaderFile('data.json', JSON.stringify(api_data)),
  818. new DownloaderFile('content.html', api_data.post.content, { date })
  819. ]);
  820.  
  821. const tasks = [
  822. // api_data.post.file
  823. (async function(file) {
  824. if (!file.path) { return; }
  825. sub_manager.add();
  826. const name = `file-${getFileName(file)}`;
  827. const url = getFileUrl(file);
  828. const blob = await sub_manager.progress(fetchBlob(url, sub_manager));
  829. folder.children.push(new DownloaderFile(
  830. name, blob, {
  831. date,
  832. comment: JSON.stringify(file)
  833. }
  834. ));
  835. }) (api_data.post.file),
  836.  
  837. // api_data.post.attachments
  838. ...api_data.post.attachments.map(async (attachment, i) => {
  839. if (!attachment.path) { return; }
  840. sub_manager.add();
  841. const prefix = utils.fillNumber(i+1, api_data.post.attachments.length.toString().length);
  842. const name = `${prefix}-${getFileName(attachment)}`;
  843. const url = getFileUrl(attachment);
  844. const blob = await sub_manager.progress(fetchBlob(url, sub_manager));
  845. const attachments_folder = new DownloaderFolder('attachments');
  846. attachments_folder.children.push(new DownloaderFile(
  847. name, blob, {
  848. date,
  849. comment: JSON.stringify(attachment)
  850. }
  851. ));
  852. folder.children.push(attachments_folder);
  853. }),
  854. ];
  855. await Promise.all(tasks);
  856.  
  857. perfmon_run.stop();
  858.  
  859. return folder;
  860.  
  861. function getFileName(file) {
  862. return file.name || file.path.slice(file.path.lastIndexOf('/')+1);
  863. }
  864.  
  865. function getFileUrl(file) {
  866. return (file.server ?? 'https://n1.kemono.su') + '/data' + file.path;
  867. }
  868. }
  869.  
  870. /**
  871. * Deliver zip file to user
  872. * @param {string} filename - filename (with extension, e.g. "file.zip")
  873. * @param {JSZip} zip,
  874. * @param {string | null} comment - zip file comment
  875. */
  876. async function saveAs(filename, zip, manager, comment=null) {
  877. const perfmon_run = perfmon.run('saveAs', filename);
  878. const sub_manager = manager.sub(100, manager.callback, {
  879. layer: 'zip', filename, zip
  880. });
  881.  
  882. const options = { type: 'blob' };
  883. comment !== null && (options.comment = comment);
  884. const blob = await zip.generateAsync(options, metadata => sub_manager.progress(null, metadata.percent));
  885. const url = URL.createObjectURL(blob);
  886. const a = $$CrE({
  887. tagName: 'a',
  888. attrs: {
  889. download: filename,
  890. href: url
  891. }
  892. });
  893. a.click();
  894.  
  895. perfmon_run.stop();
  896. }
  897.  
  898. /**
  899. * Fetch blob data from given url \
  900. * Queued function of _fetchBlob
  901. * @param {string} url
  902. * @param {utils.ProgressManager} [manager]
  903. * @returns {Promise<Blob>}
  904. */
  905. function fetchBlob(...args) {
  906. if (!fetchBlob.initialized) {
  907. queueTask.fetchBlob = {
  908. max: 3,
  909. sleep: 0
  910. };
  911. fetchBlob.initialized = true;
  912. }
  913.  
  914. return queueTask(() => _fetchBlob(...args), 'fetchBlob');
  915. }
  916.  
  917. /**
  918. * Fetch blob data from given url
  919. * @param {string} url
  920. * @param {utils.ProgressManager} [manager]
  921. * @param {number} [retry=3] - times to retry before throwing an error
  922. * @returns {Promise<Blob>}
  923. */
  924. async function _fetchBlob(url, manager, retry = 3) {
  925. const perfmon_run = perfmon.run('_fetchBlob', url);
  926. const sub_manager = manager.sub(0, manager.callback, {
  927. layer: 'file', url
  928. });
  929.  
  930. const blob = await new Promise((resolve, reject) => {
  931. GM_xmlhttpRequest({
  932. method: 'GET', url,
  933. responseType: 'blob',
  934. async onprogress(e) {
  935. sub_manager.steps = e.total;
  936. await sub_manager.progress(null, e.loaded);
  937. },
  938. onload(e) {
  939. e.status === 200 ? resolve(e.response) : onerror(e)
  940. },
  941. onerror
  942. });
  943.  
  944. async function onerror(err) {
  945. if (retry) {
  946. await sub_manager.progress(null, -1);
  947. const result = await _fetchBlob(url, manager, retry - 1);
  948. resolve(result);
  949. } else {
  950. reject(err)
  951. }
  952. }
  953. });
  954.  
  955. perfmon_run.stop();
  956.  
  957. return blob;
  958. }
  959.  
  960. return {
  961. downloadPost, downloadPosts,
  962. fetchPost, saveAs,
  963. perfmon
  964. };
  965. }
  966. }, {
  967. id: 'user-interface',
  968. dependencies: ['utils', 'api', 'downloader', 'gui'],
  969. async func() {
  970. const utils = require('utils');
  971. const api = require('api');
  972. const downloader = require('downloader');
  973. const gui = require('gui');
  974.  
  975. let downloading = false;
  976. let selector = null;
  977.  
  978. // Console User Interface
  979. const ConsoleUI = utils.window.ZIP = {
  980. version: GM_info.script.version,
  981. require,
  982.  
  983. api, downloader,
  984. get ui() { return require('user-interface') },
  985.  
  986. downloadCurrentPost, downloadCurrentCreator, userDownload, getPageType
  987. };
  988.  
  989. // Menu User Interface
  990. GM_registerMenuCommand(CONST.Text.DownloadPost, downloadCurrentPost);
  991. GM_registerMenuCommand(CONST.Text.DownloadCreator, downloadCurrentCreator);
  992.  
  993. // Graphical User Interface
  994. // Make button
  995. const dlbtn = $$CrE({
  996. tagName: 'button',
  997. styles: {
  998. 'background-color': 'transparent',
  999. 'color': 'white',
  1000. 'border': 'transparent'
  1001. },
  1002. listeners: [['click', userDownload]]
  1003. });
  1004. const dltext = $$CrE({
  1005. tagName: 'span',
  1006. props: {
  1007. innerText: CONST.Text.DownloadZip
  1008. }
  1009. });
  1010. const dlprogress = $$CrE({
  1011. tagName: 'span',
  1012. styles: {
  1013. display: 'none',
  1014. 'margin-left': '10px'
  1015. }
  1016. });
  1017. dlbtn.append(dltext);
  1018. dlbtn.append(dlprogress);
  1019.  
  1020. const board = gui.board;
  1021.  
  1022. // Place button each time a new action panel appears (meaning navigating into a post page)
  1023. let observer;
  1024. detectDom({
  1025. selector: '.post__actions, .user-header__actions',
  1026. callback: action_panel => {
  1027. // Hide dlprogress, its content is still for previous page
  1028. dlprogress.style.display = 'none';
  1029.  
  1030. // Append to action panel
  1031. action_panel.append(dlbtn);
  1032.  
  1033. // Disconnect old observer
  1034. observer?.disconnect();
  1035.  
  1036. // Observe action panel content change, always put download button in last place
  1037. observer = detectDom({
  1038. root: action_panel,
  1039. selector: 'button',
  1040. callback: btn => btn !== dlbtn && action_panel.append(dlbtn)
  1041. });
  1042. }
  1043. });
  1044.  
  1045. return {
  1046. ConsoleUI,
  1047. dlbtn, dltext,
  1048. get ['selector']() { return selector; },
  1049. downloadCurrentPost, downloadCurrentCreator,
  1050. getCurrentPost, getCurrentCreator, getPageType
  1051. }
  1052.  
  1053. function userDownload() {
  1054. const page_type = getPageType();
  1055. const func = ({
  1056. post: downloadCurrentPost,
  1057. creator: downloadCurrentCreator
  1058. })[page_type] ?? function() {};
  1059. return func();
  1060. }
  1061.  
  1062. async function downloadCurrentPost() {
  1063. const post_info = getCurrentPost();
  1064. if (downloading) { return; }
  1065. if (!post_info) { return; }
  1066.  
  1067. try {
  1068. downloading = true;
  1069. dlprogress.style.display = 'inline';
  1070. dltext.innerText = CONST.Text.Downloading;
  1071. board.minimized = false;
  1072. board.clear();
  1073. await downloader.downloadPost(
  1074. post_info.service,
  1075. post_info.creator_id,
  1076. post_info.post_id,
  1077. on_progress
  1078. );
  1079. } finally {
  1080. downloading = false;
  1081. dltext.innerText = CONST.Text.DownloadZip;
  1082. }
  1083.  
  1084. function on_progress(finished_steps, total_steps, manager) {
  1085. const info = manager.info;
  1086. info.layer === 'root' && (dlprogress.innerText = `(${finished_steps}/${total_steps})`);
  1087.  
  1088. const progress = { finished: finished_steps, total: total_steps };
  1089. info.layer === 'root' && board.update(CONST.Text.TotalProgress, progress);
  1090. info.layer === 'post' && board.update(info.data.post.title, progress);
  1091. info.layer === 'file' && board.update(info.url, progress);
  1092. info.layer === 'zip' && board.update(info.filename, progress);
  1093. }
  1094. }
  1095.  
  1096. async function downloadCurrentCreator() {
  1097. const creator_info = getCurrentCreator();
  1098. if (downloading) { return; }
  1099. if (!creator_info) { return; }
  1100.  
  1101. try {
  1102. downloading = true;
  1103. const profile = await api.Creators.profile(creator_info.service, creator_info.creator_id);
  1104. const posts = await api.Custom.all_posts(creator_info.service, creator_info.creator_id);
  1105. const selected_posts = await new Promise((resolve, reject) => {
  1106. if (!selector) {
  1107. selector = new ItemSelector();
  1108. selector.setTheme('dark');
  1109. selector.elements.container.style.setProperty('z-index', '1');
  1110. }
  1111. selector.show({
  1112. text: CONST.Text.SelectAll,
  1113. children: posts.map(post => ({
  1114. post,
  1115. text: post.title
  1116. }))
  1117. }, {
  1118. title: CONST.Text.DownloadCreator,
  1119. onok(e, json) {
  1120. const posts = json.children.map(obj => obj.post);
  1121. resolve(posts);
  1122. },
  1123. oncancel: e => resolve(null),
  1124. onclose: e => resolve(null)
  1125. });
  1126. });
  1127. if (selected_posts) {
  1128. dlprogress.style.display = 'inline';
  1129. dltext.innerText = CONST.Text.Downloading;
  1130. board.minimized = false;
  1131. board.clear();
  1132. await downloader.downloadPosts(selected_posts.map(post => ({
  1133. service: creator_info.service,
  1134. creator_id: creator_info.creator_id,
  1135. post_id: post.id
  1136. })), `${profile.name}.zip`, on_progress);
  1137. }
  1138. } finally {
  1139. downloading = false;
  1140. dltext.innerText = CONST.Text.DownloadZip;
  1141. }
  1142.  
  1143. function on_progress(finished_steps, total_steps, manager) {
  1144. const info = manager.info;
  1145. info.layer === 'root' && (dlprogress.innerText = `(${finished_steps}/${total_steps})`);
  1146.  
  1147. const progress = { finished: finished_steps, total: total_steps };
  1148. info.layer === 'root' && board.update(CONST.Text.TotalProgress, progress);
  1149. info.layer === 'post' && board.update(info.data.post.title, progress);
  1150. info.layer === 'file' && board.update(info.url, progress);
  1151. info.layer === 'zip' && board.update(info.filename, progress);
  1152. }
  1153. }
  1154.  
  1155. /**
  1156. * @typedef {Object} PostInfo
  1157. * @property {string} service,
  1158. * @property {number} creator_id
  1159. * @property {number} post_id
  1160. */
  1161. /**
  1162. * Get post info in current page
  1163. * @returns {PostInfo | null}
  1164. */
  1165. function getCurrentPost() {
  1166. const regpath = /^\/([a-zA-Z]+)\/user\/(\d+)\/post\/(\d+)/;
  1167. const match = location.pathname.match(regpath);
  1168. if (!match) {
  1169. return null;
  1170. } else {
  1171. return {
  1172. service: match[1],
  1173. creator_id: parseInt(match[2], 10),
  1174. post_id: parseInt(match[3], 10)
  1175. }
  1176. }
  1177. }
  1178.  
  1179. /**
  1180. * @typedef {Object} CreatorInfo
  1181. * @property {string} service
  1182. * @property {number} creator_id
  1183. */
  1184. /**
  1185. * Get creator info in current page
  1186. * @returns {CreatorInfo | null}
  1187. */
  1188. function getCurrentCreator() {
  1189. const regpath = /^\/([a-zA-Z]+)\/user\/(\d+)/;
  1190. const match = location.pathname.match(regpath);
  1191. if (!match) {
  1192. return null;
  1193. } else {
  1194. return {
  1195. service: match[1],
  1196. creator_id: parseInt(match[2], 10)
  1197. }
  1198. }
  1199. }
  1200.  
  1201. /** @typedef { 'post' | 'creator' } page_type */
  1202. /**
  1203. * @returns {page_type}
  1204. */
  1205. function getPageType() {
  1206. const matchers = {
  1207. post: {
  1208. type: 'regpath',
  1209. value: /^\/([a-zA-Z]+)\/user\/(\d+)\/post\/(\d+)/
  1210. },
  1211. creator: {
  1212. type: 'func',
  1213. value: () => /^\/([a-zA-Z]+)\/user\/(\d+)/.test(location.pathname) && !location.pathname.includes('/post/')
  1214. },
  1215. }
  1216. for (const [type, matcher] of Object.entries(matchers)) {
  1217. if (FunctionLoader.testCheckers(matcher)) {
  1218. return type;
  1219. }
  1220. }
  1221. }
  1222. },
  1223. }]);
  1224. }) ();