Comic Fuz Downloader

Userscript for download comics on Comic Fuz

// ==UserScript==
// @name              Comic Fuz Downloader
// @name:en           Comic Fuz Downloader
// @namespace         http://circleliu.cn
// @version           0.4.10
// @description       Userscript for download comics on Comic Fuz
// @description:en    Userscript for download comics on Comic Fuz
// @author            Circle
// @license           MIT
// @match             https://comic-fuz.com/book/viewer*
// @match             https://comic-fuz.com/magazine/viewer*
// @match             https://comic-fuz.com/manga/viewer*
// @run-at            document-start
// @grant             none

// @require           https://cdn.jsdelivr.net/npm/crypto-js@4.1.1/crypto-js.js
// @require           https://unpkg.com/axios/dist/axios.min.js
// @require           https://unpkg.com/jszip@3.6.0/dist/jszip.min.js
// @require           https://unpkg.com/jszip-utils@0.1.0/dist/jszip-utils.min.js
// @require           https://unpkg.com/jszip@3.6.0/vendor/FileSaver.js
// @require           https://unpkg.com/jquery@3.6.0/dist/jquery.min.js
// @require           https://cdn.jsdelivr.net/npm/protobufjs@6.11.2/dist/protobuf.min.js
// @require           https://cdn.jsdelivr.net/npm/lodash@4.17.21/lodash.min.js
// @require           https://cdn.jsdelivr.net/npm/piexifjs@1.0.6/piexif.min.js

// @require           https://greasyfork.org/scripts/435461-comic-fuz-downloader-protobuf-message/code/Comic%20Fuz%20Downloader%20Protobuf%20Message.js?version=987894

// @homepageURL       https://circleliu.github.io/Comic-Fuz-Downloader/
// @supportURL        https://github.com/CircleLiu/Comic-Fuz-Downloader
// ==/UserScript==

;(function () {
  'use strict'

  const DEFAULT_CONFIGS = {
    // `timeout` specifies the number of milliseconds before the request times out.
    // If the request takes longer than `timeout`, the request will be aborted/retried.
    // `0` is never timeout
    timeout: 60000,
    // The number of times to retry before failing.
    maxRetries: 3,
    //the delay in milliseconds between retried requests.
    retryDelay: 1000,
  }

  const api = getApi()

  const imgBaseUrl = 'https://img.comic-fuz.com'
  const apiBaseUrl = 'https://api.comic-fuz.com'

  const client = axios.create({
    baseURL: imgBaseUrl,
    ...DEFAULT_CONFIGS,
  })

  client.interceptors.response.use(null, (error) => {
    if (error.config && shouldRetry(error)) {
      const { __retryCount: retryCount = 0 } = error.config
      error.config.__retryCount = retryCount + 1
      const delay = error.config.retryDelay
      return new Promise((resolve) => {
        setTimeout(() => resolve(client(error.config)), delay)
      })
    }
    return Promise.reject(error)
  })

  const shouldRetry = (error) => {
    const { maxRetries, __retryCount: retryCount = 0 } = error.config
    if (retryCount < maxRetries) {
      return true
    }
    return false
  }

  const delay = ms => new Promise(resolve => setTimeout(resolve, ms))

  class Comic {
    constructor (path, request, response) {
      const deviceInfo = {
        deviceType: 2,
      }
      this.url = `${apiBaseUrl}/v1/${path}`
      this.requestBody = {
        deviceInfo,
      }
      this.request = request
      this.response = response
    }

    async fetchMetadata() {
      const response = await fetch(this.url, {
        method: 'POST',
        credentials: 'include',
        body: this.request.encode(this.requestBody).finish(),
      })
      this.metadata = await this.decodeResponse(response)
    }

    async decodeResponse(response) {
      const data = await response.arrayBuffer()
      const res = this.response.decode(new Uint8Array(data))
      return res
    }
  }

  class Book extends Comic {
    constructor (bookIssueId) {
      super('book_viewer_2', api.v1.BookViewer2Request, api.v1.BookViewer2Response)
      this.requestBody = {
        deviceInfo: this.requestBody.deviceInfo,
        bookIssueId,
        consumePaidPoint: 0,
        purchaseRequest: false,
      }
    }
  }

  class Magazine extends Comic {
    constructor (magazineIssueId) {
      super('magazine_viewer_2', api.v1.MagazineViewer2Request, api.v1.MagazineViewer2Response)
      this.requestBody = {
        deviceInfo: this.requestBody.deviceInfo,
        magazineIssueId,
        consumePaidPoint: 0,
        purchaseRequest: false,
      }
    }
  }

  class Manga extends Comic {
    constructor (chapterId) {
      super('manga_viewer', api.v1.MangaViewerRequest, api.v1.MangaViewerResponse)
      this.requestBody = {
        deviceInfo: this.requestBody.deviceInfo,
        chapterId,
        consumePoint: {
          event: 0,
          paid: 0,
        },
        useTicket: false,
      }
    }
  }

  let comic
  async function initialize() {
    const path = new URL(window.location.href).pathname.split('/')
    const type = path[path.length - 3]
    const id = path[path.length - 1]
    switch (type.toLowerCase()) {
      case 'book':
        comic = new Book(id)
        break
      case 'magazine':
        comic = new Magazine(id)
        break
      case 'manga':
        comic = new Manga(id)
        break
    }
    await comic.fetchMetadata()
  }

  async function decryptImage({imageUrl, encryptionKey, iv}) {
    const res = await client.get(imageUrl, {
      responseType: 'arraybuffer',
    })

    if (!imageUrl.includes('.enc')) {
      return btoa([].reduce.call(new Uint8Array(res.data),function(p,c){return p+String.fromCharCode(c)},''))
    }

    const cipherParams = CryptoJS.lib.CipherParams.create({
      ciphertext: CryptoJS.lib.WordArray.create(res.data)
    })
    const key = CryptoJS.enc.Hex.parse(encryptionKey)
    const _iv = CryptoJS.enc.Hex.parse(iv)
    const dcWordArray = CryptoJS.AES.decrypt(cipherParams, key, {
      iv: _iv,
      mode: CryptoJS.mode.CBC,
    })
    return dcWordArray.toString(CryptoJS.enc.Base64)
  }

  $(document).ready($ => {
    const downloadIcon = 'https://circleliu.github.io/Comic-Fuz-Downloader/icons/download.png'
    const loadingIcon = 'https://circleliu.github.io/Comic-Fuz-Downloader/icons/loading.gif'
    // const downloadIcon = 'http://localhost:5000/icons/download.png'
    // const loadingIcon = 'http://localhost:5000/icons/loading.gif'
    const divDownload = $(`
      <div id="downloader"></div>
    `)
    const path = new URL(window.location.href).pathname.split('/')
    const is_manga = (path[path.length - 3].toLowerCase()) === 'manga'
    if (is_manga) {
      divDownload.css({
        "grid-area": 'rright',
        display: 'flex',
        "align-items": 'center',
        gap: '24px',
        color: '#929ea5',
      })
    } else {
      divDownload.css({
        'margin-left': '24px',
        flex: '1 1',
        color: '#2c3438',
        width: 'fit-content',
      })
    }

    const spanDownloadButton = $(`
      <span id="downloadButton">
        <img id="downloaderIcon" src="${downloadIcon}">
        <img id="downloadingIcon" src="${loadingIcon}">
        <span id="downloaderText">Initializing</span>
      </span>
    `)
    spanDownloadButton.css({
      cursor: 'pointer',
    })
    spanDownloadButton.on('click', async () => {
      setDownloaderBusy()
      try {
        await downloadAsZip(comic.metadata, +$('#downloadFrom').val(), +$('#downloadTo').val())
        setDownloaderReady()
      } catch (error) {
        console.error(error)
        setDownloaderReady(error.message)
      }
    })

    const spanDownloadRange = $(`
      <span id="downloadRange">
        <input id="downloadFrom" type="number">~<input id="downloadTo" type="number">
      </span>
    `)
    spanDownloadRange.children('input').css({
      width: '3rem',
    })


    function initRange() {
      if (!comic.metadata) {
        throw new Error('No metadata')
      }
      const maxLength = comic.metadata.pages.filter(({image}) => !!image).length
      spanDownloadRange.children('input').attr({
        min: 1,
        max: maxLength,
      })

      $('#downloadFrom').val(1)
      $('#downloadFrom').on('input', _.debounce(() => {
        if (!$('#downloadFrom').val()) return

        const max = Math.min(+$('#downloadFrom').attr('max'), +$('#downloadTo').val())
        if (+$('#downloadFrom').val() < +$('#downloadFrom').attr('min')) {
          $('#downloadFrom').val($('#downloadFrom').attr('min'))
        } else if (+$('#downloadFrom').val() > max) {
          $('#downloadFrom').val(max)
        }
      }, 300))

      $('#downloadTo').val(maxLength)
      $('#downloadTo').on('input', _.debounce(() => {
        if (!$('#downloadTo').val()) return

        const min = Math.max(+$('#downloadTo').attr('min'), +$('#downloadFrom').val())
        if (+$('#downloadTo').val() > +$('#downloadTo').attr('max')) {
          $('#downloadTo').val($('#downloadTo').attr('max'))
        } else if (+$('#downloadTo').val() < min) {
          $('#downloadTo').val(min)
        }
      }, 300))
    }

    divDownload.append(spanDownloadButton)
    divDownload.append(spanDownloadRange)


    function setDownloaderReady(msg) {
      $('#downloaderIcon').show()
      $('#downloadingIcon').hide()
      setText(msg || 'Download')
    }

    function setDownloaderBusy() {
      $('#downloaderIcon').hide()
      $('#downloadingIcon').show()
    }

    function setText(text) {
      $('#downloaderText').text(text)
    }

    function updateDownloadProgress(progress) {
      setText(`Loading: ${progress.done}/${progress.total}`)
    }

    function checkAndLoad() {
      if ($('#downloader').length === 0) {
        if (is_manga) {
          $('div[class^="InternalViewerFooter_footer__wrapper__"]:first').append(divDownload)
        } else {
          $('div[class^="ViewerFooter_footer__buttons__"]:first').append(divDownload)
        }
      }
    }

    const maxRetry = 10
    ;(async () => {
      for (let i = 0; i < maxRetry; ++i) {
        const old_ui = !is_manga && $('div[class^="ViewerFooter_footer__"]').length
        const new_ui = is_manga && $('div[class^="InternalViewerFooter_footer__wrapper__"]').length
        if (old_ui || new_ui) {

          if (old_ui) {
            const zoomContainer = $('div[class^="ViewerFooter_footer__zoomContainer__"]:first').attr('class')
            $('head').append(`<style type="text/css">
                .${zoomContainer} {
                  flex: 0 1 270px;
                }
              </style>`)
          } else {
            const footer = $('div[class^="InternalViewerFooter_footer__wrapper__"]:first').attr('class')
            $('head').append(`<style type="text/css">
                .${footer} {
                  grid-template-areas: "left center right rright";
                  grid-template-columns: auto 1fr auto auto;
                }
              </style>`)
          }

          checkAndLoad()
          $(document).on('click', checkAndLoad)
          setDownloaderBusy()
          setText('Initializing...')
          try {
            await initialize()
            initRange()
            setDownloaderReady()
          } catch (err) {
            setDownloaderReady('Initialization failed!')
          }
          break
        } else {
          await delay(500)
        }
      }
    })()

    async function downloadAsZip(metadata, pageFrom, pageTo) {
      if (!metadata) {
        throw new Error('Failed to load data!')
      } else if (!pageFrom || !pageTo || pageFrom > pageTo) {
        throw new Error('Incorrect Range!')
      }

      const zipName = getNameFromMetadata(metadata)
      const zip = new JSZip()
      if (metadata.tableOfContents){
        zip.file('TableOfContents.txt', JSON.stringify(metadata.tableOfContents, null, '  '))
      }

      const progress = {
        total: 0,
        done: 0,
      }
      const promises = []
      const images = metadata.pages.slice(pageFrom - 1, pageTo)
      for (let i = 0; i < images.length; i++) {
        await delay(i > 0 ? 100 : 0)
        const {image} = images[i]
        if (image) {
          progress.total++
          promises.push(getImageToZip(image, zip, progress, pageFrom + i))
        }
      }
      await Promise.all(promises)

      const content = await zip.generateAsync({ type: 'blob' }, ({ percent }) => {
        setText(`Packaging: ${percent.toFixed(2)}%`)
      })
      saveAs(content, `${zipName}.zip`)
    }

    function getNameFromMetadata(metadata) {
      if (metadata.bookIssue) {
        return metadata.bookIssue.bookIssueName.trim()
      } else if (metadata.viewerTitle) {
        return metadata.sns?.body?.match(/(?<=「).*(?=」)/)?.[0]?.trim() ?? metadata.viewerTitle.trim()
      } else if (metadata.magazineIssue) {
        return metadata.magazineIssue.magazineName.trim() + ' ' + metadata.magazineIssue.magazineIssueName.trim()
      }
    }

    async function getImageToZip(image, zip, progress, index) {
      const fileName = `${index.toString().padStart(3, '0')}.jpeg`
      try {
        const imageData = await decryptImage(image)
        const imageData72Dpi = modifyExif(imageData)
        addImageToZip(fileName, imageData72Dpi, zip)
      } catch (err) {
        console.error(err)
      }
      if (progress) {
        progress.done++
        updateDownloadProgress(progress)
      }
    }

    function addImageToZip(name, base64Data, zip) {
      zip.file(name, base64Data, {
        base64: true,
      })
    }

    function modifyExif(base64Data) {
      const imageString = atob(base64Data)
      const exif = piexif.load(imageString)

      exif['0th'][piexif.ImageIFD.XResolution] = [720000,10000]
      exif['0th'][piexif.ImageIFD.YResolution] = [720000,10000]

      const newExifDump = piexif.dump(exif)

      const newData = piexif.insert(newExifDump, imageString)
      const newBase64 = btoa(newData)

      return newBase64
    }
  })
})()