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