Comic Fuz Downloader

Userscript for download comics on Comic Fuz

作者のサイトでサポートを受けることもできます。質問やレビューの投稿はこちらへ、スクリプトの通報はこちらへお寄せください。
  1. // ==UserScript==
  2. // @name Comic Fuz Downloader
  3. // @name:en Comic Fuz Downloader
  4. // @namespace http://circleliu.cn
  5. // @version 0.4.4
  6. // @description Userscript for download comics on Comic Fuz
  7. // @description:en Userscript for download comics on Comic Fuz
  8. // @author Circle
  9. // @license MIT
  10. // @match https://comic-fuz.com/book/viewer*
  11. // @match https://comic-fuz.com/magazine/viewer*
  12. // @match https://comic-fuz.com/manga/viewer*
  13. // @run-at document-start
  14. // @grant none
  15.  
  16. // @require https://cdn.jsdelivr.net/npm/crypto-js@4.1.1/crypto-js.js
  17. // @require https://unpkg.com/axios/dist/axios.min.js
  18. // @require https://unpkg.com/jszip@3.6.0/dist/jszip.min.js
  19. // @require https://unpkg.com/jszip-utils@0.1.0/dist/jszip-utils.min.js
  20. // @require https://unpkg.com/jszip@3.6.0/vendor/FileSaver.js
  21. // @require https://unpkg.com/jquery@3.6.0/dist/jquery.min.js
  22. // @require https://cdn.jsdelivr.net/npm/protobufjs@6.11.2/dist/protobuf.min.js
  23. // @require https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js
  24.  
  25. // @require https://greasyfork.org/scripts/435461-comic-fuz-downloader-protobuf-message/code/Comic%20Fuz%20Downloader%20Protobuf%20Message.js?version=987894
  26.  
  27. // @homepageURL https://circleliu.github.io/Comic-Fuz-Downloader/
  28. // @supportURL https://github.com/CircleLiu/Comic-Fuz-Downloader
  29. // ==/UserScript==
  30.  
  31. ;(function () {
  32. 'use strict'
  33.  
  34. const api = getApi()
  35.  
  36. const imgBaseUrl = 'https://img.comic-fuz.com'
  37. const apiBaseUrl = 'https://api.comic-fuz.com'
  38. class Comic {
  39. constructor (path, request, response) {
  40. const deviceInfo = {
  41. deviceType: 2,
  42. }
  43. this.url = `${apiBaseUrl}/v1/${path}`
  44. this.requestBody = {
  45. deviceInfo,
  46. }
  47. this.request = request
  48. this.response = response
  49. }
  50.  
  51. async fetchMetadata() {
  52. const response = await fetch(this.url, {
  53. method: 'POST',
  54. credentials: 'include',
  55. body: this.request.encode(this.requestBody).finish(),
  56. })
  57. this.metadata = await this.decodeResponse(response)
  58. }
  59.  
  60. async decodeResponse(response) {
  61. const data = await response.arrayBuffer()
  62. const res = this.response.decode(new Uint8Array(data))
  63. return res
  64. }
  65. }
  66.  
  67. class Book extends Comic {
  68. constructor (bookIssueId) {
  69. super('book_viewer_2', api.v1.BookViewer2Request, api.v1.BookViewer2Response)
  70. this.requestBody = {
  71. deviceInfo: this.requestBody.deviceInfo,
  72. bookIssueId,
  73. consumePaidPoint: 0,
  74. purchaseRequest: false,
  75. }
  76. }
  77. }
  78.  
  79. class Magazine extends Comic {
  80. constructor (magazineIssueId) {
  81. super('magazine_viewer_2', api.v1.MagazineViewer2Request, api.v1.MagazineViewer2Response)
  82. this.requestBody = {
  83. deviceInfo: this.requestBody.deviceInfo,
  84. magazineIssueId,
  85. consumePaidPoint: 0,
  86. purchaseRequest: false,
  87. }
  88. }
  89. }
  90.  
  91. class Manga extends Comic {
  92. constructor (chapterId) {
  93. super('manga_viewer', api.v1.MangaViewerRequest, api.v1.MangaViewerResponse)
  94. this.requestBody = {
  95. deviceInfo: this.requestBody.deviceInfo,
  96. chapterId,
  97. consumePoint: {
  98. event: 0,
  99. paid: 0,
  100. },
  101. useTicket: false,
  102. }
  103. }
  104. }
  105.  
  106. let comic
  107. async function initialize() {
  108. const path = new URL(window.location.href).pathname.split('/')
  109. const type = path[path.length - 3]
  110. const id = path[path.length - 1]
  111. // console.log(path, type, id)
  112. switch (type.toLowerCase()) {
  113. case 'book':
  114. comic = new Book(id)
  115. break
  116. case 'magazine':
  117. comic = new Magazine(id)
  118. break
  119. case 'manga':
  120. comic = new Manga(id)
  121. break
  122. }
  123. await comic.fetchMetadata()
  124. }
  125.  
  126. async function decryptImage({imageUrl, encryptionKey, iv}) {
  127. const res = await axios.get(imgBaseUrl + imageUrl, {
  128. responseType: 'arraybuffer',
  129. })
  130. const cipherParams = CryptoJS.lib.CipherParams.create({
  131. ciphertext: CryptoJS.lib.WordArray.create(res.data)
  132. })
  133. const key = CryptoJS.enc.Hex.parse(encryptionKey)
  134. const _iv = CryptoJS.enc.Hex.parse(iv)
  135. const dcWordArray = CryptoJS.AES.decrypt(cipherParams, key, {
  136. iv: _iv,
  137. mode: CryptoJS.mode.CBC,
  138. })
  139. return dcWordArray.toString(CryptoJS.enc.Base64)
  140. }
  141. $(document).ready($ => {
  142. const downloadIcon = 'https://circleliu.github.io/Comic-Fuz-Downloader/icons/download.png'
  143. const loadingIcon = 'https://circleliu.github.io/Comic-Fuz-Downloader/icons/loading.gif'
  144. // const downloadIcon = 'http://localhost:5000/icons/download.png'
  145. // const loadingIcon = 'http://localhost:5000/icons/loading.gif'
  146. const divDownload = $(`
  147. <div id="downloader"></div>
  148. `)
  149. divDownload.css({
  150. flex: '1 1',
  151. color: '#2c3438',
  152. width: 'fit-content',
  153. })
  154.  
  155. const spanDownloadButton = $(`
  156. <span id="downloadButton">
  157. <img id="downloaderIcon" src="${downloadIcon}">
  158. <img id="downloadingIcon" src="${loadingIcon}">
  159. <span id="downloaderText">Initializing</span>
  160. </span>
  161. `)
  162. spanDownloadButton.css({
  163. cursor: 'pointer',
  164. })
  165. spanDownloadButton.on('click', async () => {
  166. setDownloaderBusy()
  167. try {
  168. await downloadAsZip(comic.metadata, +$('#downloadFrom').val(), +$('#downloadTo').val())
  169. setDownloaderReady()
  170. } catch (error) {
  171. console.error(error)
  172. setDownloaderReady(error.message)
  173. }
  174. })
  175.  
  176. const spanDownloadRange = $(`
  177. <span id="downloadRange">
  178. <input id="downloadFrom" type="number">~<input id="downloadTo" type="number">
  179. </span>
  180. `)
  181. spanDownloadRange.children('input').css({
  182. width: '3rem',
  183. })
  184.  
  185. function initRange() {
  186. if (!comic.metadata) {
  187. throw new Error('No metadata')
  188. }
  189. const maxLength = comic.metadata.pages.filter(({image}) => !!image).length
  190. spanDownloadRange.children('input').attr({
  191. min: 1,
  192. max: maxLength,
  193. })
  194.  
  195. $('#downloadFrom').val(1)
  196. $('#downloadFrom').on('input', _.debounce(() => {
  197. if (!$('#downloadFrom').val()) return
  198.  
  199. const max = Math.min(+$('#downloadFrom').attr('max'), +$('#downloadTo').val())
  200. if (+$('#downloadFrom').val() < +$('#downloadFrom').attr('min')) {
  201. $('#downloadFrom').val($('#downloadFrom').attr('min'))
  202. } else if (+$('#downloadFrom').val() > max) {
  203. $('#downloadFrom').val(max)
  204. }
  205. }, 300))
  206.  
  207. $('#downloadTo').val(maxLength)
  208. $('#downloadTo').on('input', _.debounce(() => {
  209. if (!$('#downloadTo').val()) return
  210.  
  211. const min = Math.max(+$('#downloadTo').attr('min'), +$('#downloadFrom').val())
  212. if (+$('#downloadTo').val() > +$('#downloadTo').attr('max')) {
  213. $('#downloadTo').val($('#downloadTo').attr('max'))
  214. } else if (+$('#downloadTo').val() < min) {
  215. $('#downloadTo').val(min)
  216. }
  217. }, 300))
  218. }
  219.  
  220. divDownload.append(spanDownloadButton)
  221. divDownload.append(spanDownloadRange)
  222.  
  223.  
  224. function setDownloaderReady(msg) {
  225. $('#downloaderIcon').show()
  226. $('#downloadingIcon').hide()
  227. setText(msg || 'Download')
  228. }
  229.  
  230. function setDownloaderBusy() {
  231. $('#downloaderIcon').hide()
  232. $('#downloadingIcon').show()
  233. }
  234.  
  235. function setText(text) {
  236. $('#downloaderText').text(text)
  237. }
  238.  
  239. function updateDownloadProgress(progress) {
  240. setText(`Loading: ${progress.done}/${progress.total}`)
  241. }
  242.  
  243. function checkAndLoad() {
  244. if ($('#downloader').length === 0) {
  245. $('div[class^="ViewerFooter_footer__buttons__"]:first').append(divDownload)
  246. }
  247. }
  248.  
  249. const delay = ms => new Promise(resolve => setTimeout(resolve, ms))
  250. const maxRetry = 10
  251. ;(async () => {
  252. for (let i = 0; i < maxRetry; ++i) {
  253. if ($('div[class^="ViewerFooter_footer__"]').length) {
  254. const zoomContainer = $('div[class^="ViewerFooter_footer__zoomContainer__"]:first').attr('class')
  255. $('head').append(`<style type="text/css">
  256. .${zoomContainer} {
  257. flex: 0 1 270px;
  258. }
  259. </style>`)
  260.  
  261. checkAndLoad()
  262. $(document).on('click', checkAndLoad)
  263. setDownloaderBusy()
  264. setText('Initializing...')
  265. try {
  266. await initialize()
  267. initRange()
  268. // console.log(comic.metadata)
  269. setDownloaderReady()
  270. } catch (err) {
  271. setDownloaderReady('Initialization failed!')
  272. }
  273. break
  274. } else {
  275. await delay(500)
  276. }
  277. }
  278. })()
  279.  
  280. async function downloadAsZip(metadata, pageFrom, pageTo) {
  281. // console.log(typeof pageFrom, typeof pageTo)
  282. if (!metadata) {
  283. throw new Error('Failed to load data!')
  284. } else if (!pageFrom || !pageTo || pageFrom > pageTo) {
  285. throw new Error('Incorrect Range!')
  286. }
  287.  
  288. const zipName = getNameFromMetadata(metadata)
  289. const zip = new JSZip()
  290. if (metadata.tableOfContents){
  291. zip.file('TableOfContents.txt', JSON.stringify(metadata.tableOfContents, null, ' '))
  292. }
  293.  
  294. const progress = {
  295. total: 0,
  296. done: 0,
  297. }
  298. const promises = metadata.pages.slice(pageFrom - 1, pageTo).map(({image}, i) => {
  299. if (image){
  300. progress.total++
  301. return getImageToZip(image, zip, progress, pageFrom + i)
  302. }
  303. })
  304. await Promise.all(promises)
  305.  
  306. const content = await zip.generateAsync({ type: 'blob' }, ({ percent }) => {
  307. setText(`Packaging: ${percent.toFixed(2)}%`)
  308. })
  309. saveAs(content, `${zipName}.zip`)
  310. }
  311.  
  312. function getNameFromMetadata(metadata) {
  313. if (metadata.bookIssue) {
  314. return metadata.bookIssue.bookIssueName.trim()
  315. } else if (metadata.viewerTitle) {
  316. return metadata.viewerTitle.trim()
  317. } else if (metadata.magazineIssue) {
  318. return metadata.magazineIssue.magazineName.trim() + ' ' + metadata.magazineIssue.magazineIssueName.trim()
  319. }
  320. }
  321.  
  322. async function getImageToZip(image, zip, progress, index) {
  323. const fileName = `${index.toString().padStart(3, '0')}.jpeg`
  324. try {
  325. const imageData = await decryptImage(image)
  326. addImageToZip(fileName, imageData, zip)
  327. } catch (err) {
  328. console.error(err)
  329. }
  330. if (progress) {
  331. progress.done++
  332. updateDownloadProgress(progress)
  333. }
  334. }
  335. function addImageToZip(name, base64Data, zip) {
  336. zip.file(name, base64Data, {
  337. base64: true,
  338. })
  339. }
  340. })
  341. })()